devsofttech_hms_ci4/app/Libraries/Abdm/M3HiuGatewayClient.php
DevSoftTech Server b26920c6f4 ABDM bridge: allow opt-in SSL verify bypass via ABDM_BRIDGE_SSL_VERIFY env for envs with stale local CA bundles
Some local WAMP/XAMPP/Laragon PHP installs ship an outdated cacert.pem,
causing outbound HTTPS calls to the bridge to fail with cURL error 60
(SSL certificate problem). Rather than globally disabling certificate
verification (as bridge support suggested), verification stays ON by
default and can only be disabled per-deployment by explicitly setting
ABDM_BRIDGE_SSL_VERIFY=false in that machine's .env. The real fix on
affected machines is updating curl.cainfo in php.ini.
2026-07-28 03:37:23 +05:30

577 lines
21 KiB
PHP

<?php
namespace App\Libraries\Abdm;
class M3HiuGatewayClient
{
private \CodeIgniter\Database\BaseConnection $db;
private string $baseUrl;
private int $timeoutSec = 45;
public function __construct()
{
$this->db = \Config\Database::connect();
$this->baseUrl = rtrim((string) (getenv('EATRIA_BRIDGE_URL') ?: 'https://abdm-bridge.e-atria.in/api'), '/');
}
/**
* SSL peer/host verification is ON by default (secure). Some local
* WAMP/XAMPP/Laragon installs ship a stale bundled cacert.pem which makes
* outbound HTTPS calls fail with cURL error 60 (SSL certificate problem).
* Rather than disabling verification for everyone, allow an explicit,
* per-deployment opt-out via env for that specific broken environment:
* ABDM_BRIDGE_SSL_VERIFY=false
* The correct long-term fix on the affected machine is to update PHP's
* CA bundle (curl.cainfo in php.ini), not to disable verification.
*/
private function sslVerifyEnabled(): bool
{
$raw = strtolower(trim((string) (getenv('ABDM_BRIDGE_SSL_VERIFY') ?: '')));
return ! in_array($raw, ['0', 'false', 'no', 'off'], true);
}
private function sanitizeBearerToken(string $token): string
{
$token = trim($token);
if ($token === '') {
return '';
}
if (stripos($token, 'Bearer ') === 0) {
$token = trim(substr($token, 7));
}
$token = trim($token, " \t\n\r\0\x0B\"'");
$token = (string) preg_replace('/\s+/u', '', $token);
return $token;
}
public function consentRequest(array $payload): array
{
return $this->call('/v3/hiu/consent/request', $payload, 'consent.request');
}
public function consentRequestStatus(array $payload): array
{
return $this->call('/v3/hiu/consent/request/status', $payload, 'consent.request.status');
}
public function reconcileConsentStatus(array $payload): array
{
return $this->callGet('/v1/hiu/consent/status', $payload, 'consent.status.reconcile');
}
public function fetchDecryptedData(array $payload): array
{
return $this->callGet('/v1/hiu/data/fetch', $payload, 'data.fetch');
}
private function call(string $path, array $payload, string $eventKey): array
{
$settings = $this->resolveRuntimeSettings();
if (($settings['ok'] ?? 0) !== 1) {
return $settings;
}
$token = (string) ($settings['token'] ?? '');
$hfrId = (string) ($settings['hfr_id'] ?? '');
$hospitalId = (string) ($settings['hospital_id'] ?? '');
$runtimeBaseUrl = trim((string) ($settings['base_url'] ?? ''));
if ($runtimeBaseUrl !== '') {
$this->baseUrl = rtrim($runtimeBaseUrl, '/');
}
$payloadHfrId = trim((string) ($payload['hfr_id'] ?? ''));
if ($payloadHfrId !== '' && strcasecmp($payloadHfrId, $hfrId) !== 0) {
return [
'ok' => 0,
'http_code' => 400,
'error_text' => 'hfr_id mismatch: payload hfr_id does not match hospital_setting.ABDM_HFR_ID',
'retryable' => 0,
];
}
$payload['hfr_id'] = $hfrId;
$payload = $this->clampConsentDateRangeToNow($payload);
$url = $this->baseUrl . $path;
$headers = [
'Content-Type: application/json',
'Accept: application/json',
'Authorization: Bearer ' . $token,
];
if ($hospitalId !== '') {
$headers[] = 'X-Hospital-Id: ' . $hospitalId;
}
$sslVerify = $this->sslVerifyEnabled();
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeoutSec,
CURLOPT_SSL_VERIFYPEER => $sslVerify,
CURLOPT_SSL_VERIFYHOST => $sslVerify ? 2 : 0,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
$raw = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
$decoded = json_decode((string) $raw, true);
$isJson = is_array($decoded);
$ok = $isJson ? (int) ($decoded['ok'] ?? ($httpCode >= 200 && $httpCode < 300 ? 1 : 0)) : (int) ($httpCode >= 200 && $httpCode < 300);
$errorText = '';
if ($curlErr !== '') {
$errorText = $curlErr;
} elseif ($ok !== 1) {
$errorText = $this->extractErrorText($decoded, $httpCode, $isJson);
}
if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $decoded ?? [])) {
$errorText = 'Gateway edge block (CloudFront 403 Request blocked). Retry later or ask gateway team to unblock the endpoint.';
}
$retryable = 0;
if ($curlErr !== '' || $httpCode >= 500 || $httpCode === 0 || str_contains(strtolower($errorText), 'timed out')) {
$retryable = 1;
}
$response = $isJson ? $decoded : ['raw' => (string) $raw];
$response['ok'] = $ok;
$response['http_code'] = $httpCode;
$response['error_text'] = $errorText;
$response['retryable'] = $retryable;
if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $response)) {
$response['retryable'] = 1;
$response['error_text'] = 'Gateway edge block (CloudFront 403 Request blocked). Retry later or ask gateway team to unblock the endpoint.';
}
$gatewayRequestId = trim((string) ($response['request_id'] ?? $response['requestId'] ?? ''));
if ($gatewayRequestId === '') {
$gatewayRequestId = trim((string) ($payload['requestId'] ?? $payload['request_id'] ?? ''));
}
$response['gateway_request_id'] = $gatewayRequestId;
$abdmConsentRequestId = $this->findFirstValueByKeys($response, [
'consentRequestId',
'consent_request_id',
'abdm_consent_request_id',
]);
if ($abdmConsentRequestId !== '' && $this->isGatewayRefId($abdmConsentRequestId)) {
$abdmConsentRequestId = '';
}
if ($abdmConsentRequestId !== '') {
$response['abdm_consent_request_id'] = $abdmConsentRequestId;
$response['consent_request_id'] = $abdmConsentRequestId;
}
$abdmConsentId = $this->findFirstValueByKeys($response, [
'consentId',
'consent_id',
]);
if ($abdmConsentId !== '' && $this->isGatewayRefId($abdmConsentId)) {
$abdmConsentId = '';
}
if ($abdmConsentId !== '') {
$response['consent_id'] = $abdmConsentId;
$response['abdm_consent_artifact_id'] = $abdmConsentId;
}
if (! isset($response['request_id'])) {
$response['request_id'] = $gatewayRequestId;
}
if (! isset($response['transaction_id'])) {
$response['transaction_id'] = (string) ($response['transactionId'] ?? $payload['transactionId'] ?? $payload['transaction_id'] ?? '');
}
$this->writeApiLog($eventKey, $path, $payload, $response, $httpCode, $ok, $errorText, $hospitalId);
return $response;
}
private function clampConsentDateRangeToNow(array $payload): array
{
if (! isset($payload['consent']) || ! is_array($payload['consent'])) {
return $payload;
}
if (! isset($payload['consent']['permission']) || ! is_array($payload['consent']['permission'])) {
return $payload;
}
if (! isset($payload['consent']['permission']['dateRange']) || ! is_array($payload['consent']['permission']['dateRange'])) {
return $payload;
}
$toRaw = trim((string) ($payload['consent']['permission']['dateRange']['to'] ?? ''));
if ($toRaw === '') {
return $payload;
}
$safeNowUtc = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-120 seconds');
$safeNowIso = $safeNowUtc->format('Y-m-d\TH:i:s.000\Z');
try {
$toAt = new \DateTimeImmutable($toRaw);
if ($toAt->getTimestamp() > $safeNowUtc->getTimestamp()) {
$payload['consent']['permission']['dateRange']['to'] = $safeNowIso;
}
} catch (\Throwable $e) {
$payload['consent']['permission']['dateRange']['to'] = $safeNowIso;
}
return $payload;
}
private function callGet(string $path, array $query, string $eventKey): array
{
$settings = $this->resolveRuntimeSettings();
if (($settings['ok'] ?? 0) !== 1) {
return $settings;
}
$token = (string) ($settings['token'] ?? '');
$hfrId = (string) ($settings['hfr_id'] ?? '');
$hospitalId = (string) ($settings['hospital_id'] ?? '');
$runtimeBaseUrl = trim((string) ($settings['base_url'] ?? ''));
if ($runtimeBaseUrl !== '') {
$this->baseUrl = rtrim($runtimeBaseUrl, '/');
}
$payloadHfrId = trim((string) ($query['hfr_id'] ?? ''));
if ($payloadHfrId !== '' && strcasecmp($payloadHfrId, $hfrId) !== 0) {
return [
'ok' => 0,
'http_code' => 400,
'error_text' => 'hfr_id mismatch: payload hfr_id does not match hospital_setting.ABDM_HFR_ID',
'retryable' => 0,
];
}
$headers = [
'Accept: application/json',
'Authorization: Bearer ' . $token,
];
if ($hospitalId !== '') {
$headers[] = 'X-Hospital-Id: ' . $hospitalId;
}
$cleanQuery = [];
foreach ($query as $k => $v) {
if ($v === null) {
continue;
}
$val = trim((string) $v);
if ($val === '') {
continue;
}
$cleanQuery[$k] = $val;
}
// Bridge polling APIs require hfr_id in query for auth scope validation.
if ($hfrId !== '') {
$cleanQuery['hfr_id'] = $hfrId;
}
$url = $this->baseUrl . $path;
if (! empty($cleanQuery)) {
$url .= '?' . http_build_query($cleanQuery);
}
$sslVerify = $this->sslVerifyEnabled();
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeoutSec,
CURLOPT_SSL_VERIFYPEER => $sslVerify,
CURLOPT_SSL_VERIFYHOST => $sslVerify ? 2 : 0,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CUSTOMREQUEST => 'GET',
]);
$raw = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
$decoded = json_decode((string) $raw, true);
$isJson = is_array($decoded);
$ok = $isJson ? (int) ($decoded['ok'] ?? ($httpCode >= 200 && $httpCode < 300 ? 1 : 0)) : (int) ($httpCode >= 200 && $httpCode < 300);
$errorText = '';
if ($curlErr !== '') {
$errorText = $curlErr;
} elseif ($ok !== 1) {
$errorText = $this->extractErrorText($decoded, $httpCode, $isJson);
}
if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $decoded ?? [])) {
$errorText = 'Gateway edge block (CloudFront 403 Request blocked). Retry later or ask gateway team to unblock the endpoint.';
}
$retryable = 0;
if ($curlErr !== '' || $httpCode >= 500 || $httpCode === 0 || str_contains(strtolower($errorText), 'timed out')) {
$retryable = 1;
}
$response = $isJson ? $decoded : ['raw' => (string) $raw];
$response['ok'] = $ok;
$response['http_code'] = $httpCode;
$response['error_text'] = $errorText;
$response['retryable'] = $retryable;
if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $response)) {
$response['retryable'] = 1;
$response['error_text'] = 'Gateway edge block (CloudFront 403 Request blocked). Retry later or ask gateway team to unblock the endpoint.';
}
$gatewayRequestId = trim((string) ($response['request_id'] ?? $response['requestId'] ?? $cleanQuery['request_id'] ?? ''));
$response['gateway_request_id'] = $gatewayRequestId;
$abdmConsentRequestId = $this->findFirstValueByKeys($response, [
'consentRequestId',
'consent_request_id',
'abdm_consent_request_id',
]);
if ($abdmConsentRequestId !== '' && ! $this->isGatewayRefId($abdmConsentRequestId)) {
$response['abdm_consent_request_id'] = $abdmConsentRequestId;
$response['consent_request_id'] = $abdmConsentRequestId;
}
$abdmConsentId = $this->findFirstValueByKeys($response, [
'consentId',
'consent_id',
'abdm_consent_artifact_id',
]);
if ($abdmConsentId !== '' && ! $this->isGatewayRefId($abdmConsentId)) {
$response['consent_id'] = $abdmConsentId;
$response['abdm_consent_artifact_id'] = $abdmConsentId;
}
// Per bridge contract, consent status/date_range/expiry come back nested
// under `consent{}` (e.g. { consent: { status: "GRANTED", ... } }) rather
// than at the top level. Flatten them so resolveState()/snapshot logic
// (which reads $response['consent_status']) can see the real value —
// otherwise GRANTED consents keep getting misclassified as pending.
$abdmConsentStatus = $this->findFirstValueByKeys($response, ['status']);
if ($abdmConsentStatus !== '') {
$response['consent_status'] = strtoupper($abdmConsentStatus);
}
if (isset($response['consent']) && is_array($response['consent'])) {
foreach (['date_range', 'dateRange', 'expiry', 'purpose', 'hi_types', 'abha_address', 'granted_at'] as $flattenKey) {
if (! isset($response[$flattenKey]) && isset($response['consent'][$flattenKey])) {
$response[$flattenKey] = $response['consent'][$flattenKey];
}
}
}
if (! isset($response['transaction_id'])) {
$response['transaction_id'] = (string) ($response['transactionId'] ?? $cleanQuery['transaction_id'] ?? '');
}
$this->writeApiLog($eventKey, $path, $cleanQuery, $response, $httpCode, $ok, $errorText, $hospitalId, 'GET');
return $response;
}
private function resolveRuntimeSettings(): array
{
if (! $this->db->tableExists('hospital_setting')) {
return ['ok' => 0, 'http_code' => 500, 'error_text' => 'hospital_setting table not found', 'retryable' => 0];
}
$settingFields = $this->db->getFieldNames('hospital_setting') ?? [];
$orderCol = null;
foreach (['id', 's_id'] as $candidateOrderCol) {
if (in_array($candidateOrderCol, $settingFields, true)) {
$orderCol = $candidateOrderCol;
break;
}
}
$rowsBuilder = $this->db->table('hospital_setting')
->select('s_name, s_value')
->whereIn('s_name', [
'EATRIA_BRIDGE_TOKEN',
'ABDM_HFR_ID',
'H_HFR_ID',
'ABDM_HOSPITAL_HFR_ID',
'ABDM_HOSPITAL_ID',
'ABDM_BRIDGE_HOSPITAL_ID',
'EATRIA_BRIDGE_HOSPITAL_ID',
'EATRIA_BRIDGE_URL',
]);
if ($orderCol !== null) {
$rowsBuilder->orderBy($orderCol, 'DESC');
}
$rows = $rowsBuilder->get()->getResultArray();
$kv = [];
foreach ($rows as $row) {
$key = trim((string) ($row['s_name'] ?? ''));
if ($key === '' || array_key_exists($key, $kv)) {
continue;
}
$kv[$key] = trim((string) ($row['s_value'] ?? ''));
}
$token = $this->sanitizeBearerToken((string) ($kv['EATRIA_BRIDGE_TOKEN'] ?? ''));
$hfrId = (string) ($kv['ABDM_HFR_ID'] ?? $kv['H_HFR_ID'] ?? $kv['ABDM_HOSPITAL_HFR_ID'] ?? '');
$hospitalId = (string) ($kv['ABDM_HOSPITAL_ID'] ?? $kv['ABDM_BRIDGE_HOSPITAL_ID'] ?? $kv['EATRIA_BRIDGE_HOSPITAL_ID'] ?? '');
$baseUrl = trim((string) ($kv['EATRIA_BRIDGE_URL'] ?? ''));
if ($token === '') {
return [
'ok' => 0,
'http_code' => 401,
'error_text' => 'Missing token: set hospital_setting.EATRIA_BRIDGE_TOKEN',
'retryable' => 0,
];
}
if ($hfrId === '') {
return [
'ok' => 0,
'http_code' => 400,
'error_text' => 'Missing hfr_id: set hospital_setting.ABDM_HFR_ID',
'retryable' => 0,
];
}
return [
'ok' => 1,
'token' => $token,
'hfr_id' => $hfrId,
'hospital_id' => $hospitalId,
'base_url' => $baseUrl,
];
}
private function writeApiLog(
string $eventKey,
string $path,
array $request,
array $response,
int $httpCode,
int $ok,
string $errorText,
string $hospitalId,
string $httpMethod = 'POST'
): void {
if (! $this->db->tableExists('abdm_api_logs')) {
return;
}
$status = $ok === 1 ? 'success' : 'error';
$entityId = trim((string) (
$request['consent_id']
?? $request['consentId']
?? $request['consentRequestId']
?? $request['request_id']
?? $request['requestId']
?? $request['transaction_id']
?? $request['transactionId']
?? ''
));
$this->db->table('abdm_api_logs')->insert([
'channel' => 'eatria_bridge',
'event_type' => 'abdm.m3.hiu.' . $eventKey,
'endpoint' => $this->baseUrl . $path,
'http_method' => strtoupper(trim($httpMethod)) !== '' ? strtoupper(trim($httpMethod)) : 'POST',
'entity_type' => 'abdm_hiu',
'entity_id' => $entityId,
'request_json' => (string) json_encode($request, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'response_code' => $httpCode,
'response_json' => (string) json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'status' => $status,
'error_message' => $errorText !== '' ? mb_substr($errorText, 0, 1000) : null,
'created_at' => date('Y-m-d H:i:s'),
]);
}
private function extractErrorText($decoded, int $httpCode, bool $isJson): string
{
if (! $isJson || ! is_array($decoded)) {
return 'HTTP ' . $httpCode . ' non-JSON response';
}
$code = trim((string) (
$decoded['data']['error']['code']
?? $decoded['error']['code']
?? ''
));
$message = trim((string) (
$decoded['data']['error']['message']
?? $decoded['error']['message']
?? $decoded['message']
?? $decoded['error_text']
?? (is_string($decoded['error'] ?? null) ? $decoded['error'] : '')
));
$combined = trim(($code !== '' ? $code . ' ' : '') . $message);
if ($combined !== '') {
return $combined;
}
return 'HTTP ' . $httpCode;
}
private function findFirstValueByKeys(array $root, array $keys): string
{
$queue = [$root];
while (! empty($queue)) {
$node = array_shift($queue);
if (! is_array($node)) {
continue;
}
foreach ($keys as $key) {
if (isset($node[$key])) {
$value = trim((string) $node[$key]);
if ($value !== '') {
return $value;
}
}
}
foreach ($node as $child) {
if (is_array($child)) {
$queue[] = $child;
}
}
}
return '';
}
private function isGatewayRefId(string $value): bool
{
return preg_match('/^REQ-/i', trim($value)) === 1;
}
private function isCloudFrontBlockedResponse(int $httpCode, $raw, array $response): bool
{
if ($httpCode !== 403) {
return false;
}
$text = strtolower((string) $raw);
if ($text === '' && isset($response['data']['raw'])) {
$text = strtolower((string) $response['data']['raw']);
}
return str_contains($text, 'generated by cloudfront')
|| str_contains($text, 'request blocked')
|| str_contains($text, 'the request could not be satisfied');
}
}