diff --git a/utilities/.gitignore b/utilities/.gitignore new file mode 100644 index 00000000..2d54267f --- /dev/null +++ b/utilities/.gitignore @@ -0,0 +1,11 @@ +# Ballerina generates this directory during the compilation of a package. +# It contains compiler-generated artifacts and the final executable if this is an application package. +target/ + +# Ballerina maintains the compiler-generated source code here. +# Remove this if you want to commit generated sources. +generated/ + +# Contains configuration values used during development time. +# See https://ballerina.io/learn/provide-values-to-configurable-variables/ for more details. +Config.toml diff --git a/utilities/Ballerina.toml b/utilities/Ballerina.toml new file mode 100644 index 00000000..34e2828a --- /dev/null +++ b/utilities/Ballerina.toml @@ -0,0 +1,10 @@ +[package] +org = "wso2" +name = "utilities" +version = "1.0.0" +distribution = "2201.13.1" +keywords = ["Healthcare", "FHIR", "utilities"] + + +[build-options] +observabilityIncluded = true diff --git a/utilities/constants.bal b/utilities/constants.bal new file mode 100644 index 00000000..dff78435 --- /dev/null +++ b/utilities/constants.bal @@ -0,0 +1,31 @@ +// Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +const AUTHORIZATION_HEADER = "authorization"; + +configurable string fhirServerUrl = ""; +configurable string asgServerUrl = ""; +configurable string orgResolverServiceUrl = ""; +configurable int proxyServerPort = 9090; +configurable string[] publicEndpoints = []; +configurable string adminAppClientId = ""; +configurable string adminAppClientSecret = ""; +configurable string[] audience = []; +configurable string tokenEp = ""; + +configurable string smart_style_url = ""; +configurable boolean need_patient_banner = false; +configurable string patient_id = ""; diff --git a/utilities/jwtValidator.bal b/utilities/jwtValidator.bal new file mode 100644 index 00000000..e4e5f054 --- /dev/null +++ b/utilities/jwtValidator.bal @@ -0,0 +1,123 @@ +// Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/http; +import ballerina/jwt; +import ballerina/log; + +// OAuth Client Authentication Exception +type OAuthClientAuthnException record {| + string message; + string errorCode?; +|}; + +type ClientAssertionValidationResponse record {| + boolean isValid; + string clientId?; + string clientSecret?; +|}; + +type AsgAppResponse record { + Application[] applications; +}; + +type Application record { + string id; + string clientId; + AdvancedConfigurations advancedConfigurations; +}; + +type AdvancedConfigurations record { + json[] additionalSpProperties; +}; + +type OIDCInboundProtocolConfig record { + string clientId; + string clientSecret; +}; + +// JWT validation function - Ballerina equivalent of the Java isValidAssertion method +isolated function isValidAssertion(string? jwtString) returns ClientAssertionValidationResponse|OAuthClientAuthnException { + + if jwtString is () { + string errorMessage = "No valid JWT assertion found for JWT Bearer Grant Type"; + return logAndThrowException(errorMessage, ()); + } + + do { + [jwt:Header, jwt:Payload] [_, payload] = check jwt:decode(jwtString); + string jwtSubject = resolveSubject(payload) ?: ""; + + // get jwks uri from asgardeo + AsgAppResponse|http:ClientError appData = + asgAdminClient->get(string `api/server/v1/applications?filter=clientId+co+${jwtSubject}=&attributes=advancedConfigurations`); + if appData is http:ClientError { + string errorMessage = "Error while retrieving application details for clientId: " + jwtSubject; + return logAndThrowException(errorMessage, ()); + } + string appId = appData.applications[0].id; + string jwksUri = ""; + json[] additionalSpProperties = appData.applications[0].advancedConfigurations.additionalSpProperties; + foreach json item in additionalSpProperties { + if (check item.name).toString() == "jwksURI" { + jwksUri = (check item.value).toString(); + break; + } + } + + jwt:ValidatorConfig validatorConfig = { + issuer: jwtSubject, + audience: audience, + clockSkew: 60, + signatureConfig: { + jwksConfig: { + url: jwksUri + } + } + }; + + // Validates the created JWT and extracts clientId clientSecret. + jwt:Payload validatedPayload = check jwt:validate(jwtString, validatorConfig); + log:printDebug("JWT is valid. Payload: " + validatedPayload.toJsonString()); + string clientId = validatedPayload.hasKey("sub") ? validatedPayload.get("sub").toString() : ""; + + OIDCInboundProtocolConfig|http:ClientError oidcProtocols = + asgAdminClient->get(string `api/server/v1/applications/${appId}/inbound-protocols/oidc`); + if oidcProtocols is http:ClientError { + string errorMessage = "Error while retrieving OIDC protocol details for clientId: " + jwtSubject; + return logAndThrowException(errorMessage, ()); + } + + string clientSecret = oidcProtocols.clientSecret; + log:printDebug("JWT assertion validated successfully for clientId: " + clientId); + return {isValid: true, clientId: clientId, clientSecret: clientSecret}; + } on fail error e { + string errorMessage = "JWT validation failed: " + e.message(); + return logAndThrowException(errorMessage, "invalid_client"); + } +} + +isolated function resolveSubject(jwt:Payload payload) returns string? { + return payload.hasKey("sub") ? payload.get("sub").toString() : (); +} + +isolated function logAndThrowException(string message, string? errorCode) returns OAuthClientAuthnException { + log:printError(message); + return { + message: message, + errorCode: errorCode ?: "server_error" + }; +} diff --git a/utilities/proxy_service.bal b/utilities/proxy_service.bal new file mode 100644 index 00000000..e9946c39 --- /dev/null +++ b/utilities/proxy_service.bal @@ -0,0 +1,236 @@ +// Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/http; +import ballerina/lang.array; +import ballerina/log; + +final http:Client fhirClient = check new (fhirServerUrl); +final http:Client asgAdminClient = check new (asgServerUrl, + auth = { + tokenUrl: tokenEp, + clientId: adminAppClientId, + clientSecret: adminAppClientSecret, + scopes: "internal_application_mgt_view" + } +); +final http:Client asgClient = check new (asgServerUrl); + +service / on new http:Listener(proxyServerPort) { + + isolated resource function get [string... path](http:Request httpRequest) returns http:Response|error { + if path[path.length() -1] == "authorize" { + // Redirect to ASG authorize endpoint + // Extract raw query string to preserve encoding (e.g., + signs in timestamps) + string rawPath = httpRequest.rawPath; + string queryString = ""; + int? queryIndex = rawPath.indexOf("?"); + if queryIndex is int && queryIndex >= 0 { + queryString = rawPath.substring(queryIndex + 1); + } + string fullPath = queryString != "" ? "oauth2/authorize?" + queryString : "oauth2/authorize"; + log:printDebug(string `Forwarding authorize GET request to ASG server. Path: ${fullPath}`); + return asgClient->get(fullPath, {}); + } + return handleRequest(httpRequest, path, "GET"); + } + + isolated resource function post [string... path](http:Request httpRequest) returns http:Response|error { + if path[path.length() - 1] == "token" { + // Redirect to ASG token endpoint + // Get the raw form payload to preserve Content-Type + string payload = check httpRequest.getTextPayload(); + map formParams = check httpRequest.getFormParams(); + map headers = extractHeaders(httpRequest); + + string? grant_type = formParams.hasKey("grant_type") ? formParams["grant_type"] : ""; + + // Handle pwt key jwt assertions. + if formParams.hasKey("client_assertion") { + string? client_assertion_type = formParams["client_assertion_type"]; + if client_assertion_type is () || client_assertion_type != + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { + log:printError("Invalid client_assertion_type for JWT Bearer Grant Type"); + http:Response unauthorizedResponse = new; + unauthorizedResponse.statusCode = 400; + unauthorizedResponse.setJsonPayload({"error": "Bad request: Invalid client_assertion_type"}); + return unauthorizedResponse; + } + string? jwt = formParams["client_assertion"]; + ClientAssertionValidationResponse|OAuthClientAuthnException client_assertion = isValidAssertion(jwt); + if client_assertion is OAuthClientAuthnException || !client_assertion.isValid { + log:printError("Invalid JWT Assertion", errorMsg = client_assertion.toBalString()); + http:Response unauthorizedResponse = new; + unauthorizedResponse.statusCode = 401; + unauthorizedResponse.setJsonPayload({"error": "Unauthorized: Invalid JWT Assertion"}); + return unauthorizedResponse; + } + + // Remove client_assertion from form params and rebuild payload + _ = formParams.remove("client_assertion"); + _ = formParams.remove("client_assertion_type"); + + // Rebuild the form-encoded payload without client_assertion + string[] payloadParts = []; + foreach [string, string] [key, value] in formParams.entries() { + payloadParts.push(key + "=" + value); + } + payload = string:'join("&", ...payloadParts); + + headers = addBasicAuthHeader({}, client_assertion.clientId ?: "", client_assertion.clientSecret ?: ""); + } else { + // remove any unnecessary headers and forward only authorization header + string[] authHeader = headers.hasKey("Authorization") ? headers.get("Authorization") : + (headers.hasKey("authorization") ? headers.get("authorization") : []); + + string[] userAgent = headers.hasKey("User-Agent") ? headers.get("User-Agent") : + (headers.hasKey("user-agent") ? headers.get("user-agent") : ["Mozilla/5.0 (Ballerina)"]); + + // Always include Content-Type for form data and User-Agent + headers = { + "Content-Type": ["application/x-www-form-urlencoded"], + "User-Agent": userAgent + }; + + if authHeader.length() > 0 { + headers["Authorization"] = authHeader; + } + } + http:Response|http:ClientError res = asgClient->post("oauth2/token", payload, headers); + if res is http:Response && res.statusCode == 200 && grant_type == "authorization_code" { + map resPayload = check res.getJsonPayload().ensureType(); + if smart_style_url != "" { + resPayload["smart_style_url"] = smart_style_url; + } + resPayload["need_patient_banner"] = need_patient_banner; + // id token fhiruser claim + resPayload["patient"] = patient_id; + res.setJsonPayload(resPayload); + } + return res; + } + return handleRequest(httpRequest, path, "POST"); + } + + isolated resource function patch [string... path](http:Request httpRequest) returns http:Response|error { + return handleRequest(httpRequest, path, "PATCH"); + } + + isolated resource function put [string... path](http:Request httpRequest) returns http:Response|error { + return handleRequest(httpRequest, path, "PUT"); + } + + isolated resource function delete [string... path](http:Request httpRequest) returns http:Response|error { + return handleRequest(httpRequest, path, "DELETE"); + } +} + +// Extract headers from HTTP request +isolated function extractHeaders(http:Request httpRequest) returns map { + map headers = {}; + foreach string headerName in httpRequest.getHeaderNames() { + string[]|http:HeaderNotFoundError headerResult = httpRequest.getHeaders(headerName); + if headerResult is string[] { + headers[headerName] = headerResult; + } + } + return headers; +} + +// Add basic auth header if credentials are configured +isolated function addBasicAuthHeader(map headers, string basicAuthUsername, string basicAuthPassword) + returns map { + if basicAuthUsername != "" && basicAuthPassword != "" { + string credentials = basicAuthUsername + ":" + basicAuthPassword; + string encodedCredentials = array:toBase64(credentials.toBytes()); + headers["Authorization"] = ["Basic " + encodedCredentials]; + } + return headers; +} + +// Create unauthorized response +isolated function createUnauthorizedResponse() returns http:Response { + http:Response unauthorizedResponse = new; + unauthorizedResponse.statusCode = 401; + unauthorizedResponse.setJsonPayload({"error": "Unauthorized: Invalid organization"}); + return unauthorizedResponse; +} + +// Common request handler to eliminate code duplication +isolated function handleRequest(http:Request httpRequest, string[] path, string method) returns http:Response|error { + string reqPath = string:'join("/", ...path); + + // Extract headers + map headers = extractHeaders(httpRequest); + + // Extract raw query string to preserve encoding (e.g., + signs in timestamps) + string rawPath = httpRequest.rawPath; + string queryString = ""; + int? queryIndex = rawPath.indexOf("?"); + if queryIndex is int && queryIndex >= 0 { + queryString = rawPath.substring(queryIndex + 1); + } + string fullPath = queryString != "" ? reqPath + "?" + queryString : reqPath; + + log:printDebug(string `Forwarding ${method} request to FHIR server. Path: ${fullPath}, Headers: ${headers.toString()}`); + + // Make the appropriate HTTP call based on method + match method { + "GET" => { + return fhirClient->get(fullPath, headers); + } + "DELETE" => { + return fhirClient->delete(fullPath, headers); + } + "POST"|"PATCH"|"PUT" => { + + // Extract payload based on content type + string|json payload; + string|http:HeaderNotFoundError contentTypeResult = httpRequest.getHeader("Content-Type"); + string contentType = contentTypeResult is string ? contentTypeResult : ""; + + if contentType.toLowerAscii().includes("application/x-www-form-urlencoded") { + payload = check httpRequest.getTextPayload(); + } else { + payload = check httpRequest.getJsonPayload(); + } + + match method { + "POST" => { + return fhirClient->post(fullPath, payload, headers); + } + "PATCH" => { + return fhirClient->patch(fullPath, payload, headers); + } + "PUT" => { + return fhirClient->put(fullPath, payload, headers); + } + _ => { + http:Response methodNotAllowedResponse = new; + methodNotAllowedResponse.statusCode = 405; + methodNotAllowedResponse.setJsonPayload({"error": "Method Not Allowed"}); + return methodNotAllowedResponse; + } + } + } + _ => { + http:Response methodNotAllowedResponse = new; + methodNotAllowedResponse.statusCode = 405; + methodNotAllowedResponse.setJsonPayload({"error": "Method Not Allowed"}); + return methodNotAllowedResponse; + } + } +}