From 7532af23b66bd02ca3717bf0a59ce571aad0b8b9 Mon Sep 17 00:00:00 2001 From: Stephen Nielson Date: Tue, 22 Dec 2020 20:23:16 -0500 Subject: [PATCH] SMART-FHIR Capability & .well-known statement (#4090) * SMART-FHIR Capability & .well-known statement SMART requires both the FHIR capability statement and a .well-known/smart-configuration url to be provided at the root of the FHIR server. Implemented this requirement as well as providing the baseline capabilities that ONC requires for the SMART FHIR. Implemented the mapping from the capability statement's read/insert/update statements onto SMART scopes scopes that can be requested by the SMART app. * Fixing checkstyle issues. * SMART launch/patient scope, CORS enabled, logging Bunch of work to enable the standalone SMART app launch with the launch/patient scope for SMART. This replaces the current IdTokenResponse class used in the oauth2 authentication process with an IdTokenSMARTResponse which will be able to add on context dependent data that is sent back to the client parallel to the accessToken and idToken. Right now it just grabs the very first patient available in the system. It needs to be wired up so that a provider gets a patient selector or when a patient logs in it sends that patient information as part of the external app launch This also enables CORS support for the browser based oauth2 requests and sets the OPTIONS requests to bypass any token validation / support. Implemented a PSR3 logger which wraps around the Monolog logger package. Wrote a Decorator/Adapter class around the Monolog logger so the system isn't tightly coupled to Monolog and we can replace it if its ever needed. The logging needs to be tied to somekind of global config so we can decide the log level as needed. These logs are currently NOT intended for system level auditing, but writes directly to the error log. Anything higher than the currently set system log level is treated like a noop. The logging helped immensely in tracking down OAUTH2 problems. * SMART-FHIR Capability & .well-known statement SMART requires both the FHIR capability statement and a .well-known/smart-configuration url to be provided at the root of the FHIR server. Implemented this requirement as well as providing the baseline capabilities that ONC requires for the SMART FHIR. Implemented the mapping from the capability statement's read/insert/update statements onto SMART scopes scopes that can be requested by the SMART app. * Fixing checkstyle issues. * SMART launch/patient scope, CORS enabled, logging Bunch of work to enable the standalone SMART app launch with the launch/patient scope for SMART. This replaces the current IdTokenResponse class used in the oauth2 authentication process with an IdTokenSMARTResponse which will be able to add on context dependent data that is sent back to the client parallel to the accessToken and idToken. Right now it just grabs the very first patient available in the system. It needs to be wired up so that a provider gets a patient selector or when a patient logs in it sends that patient information as part of the external app launch This also enables CORS support for the browser based oauth2 requests and sets the OPTIONS requests to bypass any token validation / support. Implemented a PSR3 logger which wraps around the Monolog logger package. Wrote a Decorator/Adapter class around the Monolog logger so the system isn't tightly coupled to Monolog and we can replace it if its ever needed. The logging needs to be tied to somekind of global config so we can decide the log level as needed. These logs are currently NOT intended for system level auditing, but writes directly to the error log. Anything higher than the currently set system log level is treated like a noop. The logging helped immensely in tracking down OAUTH2 problems. * Fix wierd merge bug. Had a wierd merge bug that redeclared a variable. Should be fixed now. * Fix PHP7.4, PHP8 broken tests cases. * Support patch. * Remove PATCH, fix styles. Hopefully this fixes all the style problems * Fix style issues, add log escapes. Figured out why the case statements weren't working. The style checker doesn't like compound brace statements under a case statement. Refactored the SystemLogger to escape all context variables. Any variable needing to be escaped should be put in as a context variable. * Fix php error. * Populate Patient id context from session. * Insertion point for SMART app launch on demographics * SMART EHR launch context support Implemented the 'launch' SMART Core Context for in EHR launching with a patient selected. Added a display of the registered SMART apps as part of the patient demographics. Right now this is a placeholder of where we can put it to generate ideas of what we can do to display the SMART apps. It filters out from the registered client applications ONLY those that have registered as a SMART application by providing the 'launch' scope as part of their initial registration. It assumes that the registered app has a 'launch.html' file which appears to be pretty widespread standard. However, we may need to add a redirect_launch_uri or something. The spec is vague and needs to be dug into a bit to see if there is a standard, or if there's an industry best practice here. * SMART Core Capability context_style,context_banner Implemented the two smart core capabilities for the banner and context style. Created a basic developer app registration. Right now it registers everything as a smart app, will need to provide developers the option of specifying what permissions they need. Currently it directly enables the developer apps, but an admin panel will need to be setup to enable/disable the dynamic registration for the developer apps. After a registration is successful the client id is returned to the registrant. Implemented pulling up the SMART app in a dialog from the patient demographics page. You can test this out by running a modified version of the Growth Charts open source app hosted at https://adunsulag.github.io/growth-chart-app/ Register a client with the redirect_uri to be: https://adunsulag.github.io/growth-chart-app/ and the logout uri to be https://adunsulag.github.io/growth-chart-app/logout Open a patient and at the bottom of the demographics you will now have a Launch App button. Launch the app and enter in the Client ID from your registration process. If you need to clear the client_id for testing you can do so at https://adunsulag.github.io/growth-chart-app/deregister.html * Fix styles * Cleanup Capability and remove Rest quickfix. Removed the quickfix on the rest controller helper that I put in until Jerry's fix was in. Cleaned up the capability statement here. * Fix code review requests. Fixed copyright notices, security escapes, and other stuff. * Change up the issuer url to be consistent. * Missing copyright header * Fix style issue on test file. Co-authored-by: Stephen Nielson --- _rest_config.php | 14 +- _rest_routes.inc.php | 6 + apis/dispatch.php | 44 ++++- composer.json | 4 +- composer.lock | 97 +++++++++- interface/patient_file/summary/demographics.php | 15 +- interface/smart/register-app.php | 159 ++++++++++++++++ library/dialog.js | 7 + oauth2/authorize.php | 12 +- public/smart-styles/smart-light.json | 13 ++ .../Auth/OpenIDConnect/Entities/ClientEntity.php | 36 ++++ .../Auth/OpenIDConnect/IdTokenSMARTResponse.php | 137 ++++++++++++++ .../Repositories/ClientRepository.php | 67 ++++++- .../OpenIDConnect/Repositories/ScopeRepository.php | 26 +++ src/Common/Http/HttpRestRouteHandler.php | 3 + src/Common/Logging/SystemLogger.php | 210 +++++++++++++++++++++ src/Events/PatientDemographics/RenderEvent.php | 64 +++++++ src/FHIR/SMART/Capability.php | 76 ++++++++ src/FHIR/SMART/SMARTLaunchToken.php | 138 ++++++++++++++ src/FHIR/SMART/SmartLaunchController.php | 168 +++++++++++++++++ src/RestControllers/AuthorizationController.php | 172 ++++++++++++++++- .../FHIR/FhirMetaDataRestController.php | 78 +++++++- .../SMART/SMARTConfigurationController.php | 152 +++++++++++++++ src/Services/PatientService.php | 15 +- tests/Tests/Api/CapabilityFhirTest.php | 97 ++++++++++ ...lityFhirTest.php => SmartConfigurationTest.php} | 37 ++-- .../OpenIDConnect/Entities/ClientEntityTest.php | 32 ++++ .../Tests/Unit/FHIR/SMART/SMARTLaunchTokenTest.php | 62 ++++++ 28 files changed, 1889 insertions(+), 52 deletions(-) create mode 100644 interface/smart/register-app.php create mode 100644 public/smart-styles/smart-light.json create mode 100644 src/Common/Auth/OpenIDConnect/IdTokenSMARTResponse.php create mode 100644 src/Common/Logging/SystemLogger.php create mode 100644 src/Events/PatientDemographics/RenderEvent.php create mode 100644 src/FHIR/SMART/Capability.php create mode 100644 src/FHIR/SMART/SMARTLaunchToken.php create mode 100644 src/FHIR/SMART/SmartLaunchController.php create mode 100644 src/RestControllers/SMART/SMARTConfigurationController.php copy tests/Tests/Api/{CapabilityFhirTest.php => SmartConfigurationTest.php} (58%) create mode 100644 tests/Tests/Unit/Common/Auth/OpenIDConnect/Entities/ClientEntityTest.php create mode 100644 tests/Tests/Unit/FHIR/SMART/SMARTLaunchTokenTest.php diff --git a/_rest_config.php b/_rest_config.php index bcfa75a45..6c39fd31d 100644 --- a/_rest_config.php +++ b/_rest_config.php @@ -23,6 +23,7 @@ use Nyholm\Psr7Server\ServerRequestCreator; use OpenEMR\Common\Acl\AclMain; use OpenEMR\Common\Auth\OpenIDConnect\Repositories\AccessTokenRepository; use OpenEMR\Common\Logging\EventAuditLogger; +use OpenEMR\Common\Logging\SystemLogger; use OpenEMR\Common\Uuid\UuidRegistry; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -194,6 +195,7 @@ class RestConfig public static function verifyAccessToken() { + $logger = SystemLogger::instance(); $response = self::createServerResponse(); $request = self::createServerRequest(); $server = new ResourceServer( @@ -203,8 +205,10 @@ class RestConfig try { $raw = $server->validateAuthenticatedRequest($request); } catch (OAuthServerException $exception) { + $logger->error("RestConfig->verifyAccessToken() OAuthServerException", ["message" => $exception->getMessage()]); return $exception->generateHttpResponse($response); } catch (\Exception $exception) { + $logger->error("RestConfig->verifyAccessToken() Exception", ["message" => $exception->getMessage()]); return (new OAuthServerException($exception->getMessage(), 0, 'unknown_error', 500)) ->generateHttpResponse($response); } @@ -328,7 +332,15 @@ class RestConfig http_response_code(400); exit(); } - return ($resource === ("/" . self::$SITE . "/fhir/metadata")); + // let the capability statement for FHIR or the SMART-on-FHIR through + if ( + $resource === ("/" . self::$SITE . "/fhir/metadata") || + $resource === ("/" . self::$SITE . "/fhir/.well-known/smart-configuration") + ) { + return true; + } else { + return false; + } } public static function apiLog($response = '', $requestBody = ''): void diff --git a/_rest_routes.inc.php b/_rest_routes.inc.php index d9bc71907..c10ec0848 100644 --- a/_rest_routes.inc.php +++ b/_rest_routes.inc.php @@ -594,6 +594,12 @@ RestConfig::$FHIR_ROUTE_MAP = array( RestConfig::apiLog($return); return $return; }, + "GET /fhir/.well-known/smart-configuration" => function () { + $authController = new \OpenEMR\RestControllers\AuthorizationController(); + $return = (new \OpenEMR\RestControllers\SMART\SMARTConfigurationController($authController))->getConfig(); + RestConfig::apiLog($return); + return $return; + }, "POST /fhir/Patient" => function () { RestConfig::authorization_check("patients", "demo"); $data = (array) (json_decode(file_get_contents("php://input"), true)); diff --git a/apis/dispatch.php b/apis/dispatch.php index 389187488..26c360134 100644 --- a/apis/dispatch.php +++ b/apis/dispatch.php @@ -19,6 +19,7 @@ require_once("./../_rest_config.php"); use OpenEMR\Common\Auth\UuidUserAccount; use OpenEMR\Common\Csrf\CsrfUtils; use OpenEMR\Common\Http\HttpRestRouteHandler; +use OpenEMR\Common\Logging\SystemLogger; use OpenEMR\Events\RestApiExtend\RestApiCreateEvent; use Psr\Http\Message\ResponseInterface; @@ -27,6 +28,8 @@ $routes = array(); // Parse needed information from Redirect or REQUEST_URI $resource = $gbl::getRequestEndPoint(); +$logger = SystemLogger::instance(); +$logger->debug("dispatch.php requested", ["resource" => $resource, "method" => $_SERVER['REQUEST_METHOD']]); $skipApiAuth = false; if (!empty($_SERVER['HTTP_APICSRFTOKEN'])) { @@ -50,6 +53,7 @@ if (!empty($_SERVER['HTTP_APICSRFTOKEN'])) { // ensure token is valid $tokenRaw = $gbl::verifyAccessToken(); if ($tokenRaw instanceof ResponseInterface) { + $logger->error("dispatch.php failed token verify for resource", ["resource" => $resource]); // failed token verify // not a request object so send the error as response obj $gbl::emitResponse($tokenRaw); @@ -71,7 +75,7 @@ if (!empty($_SERVER['HTTP_APICSRFTOKEN'])) { } // ensure 1) sane site 2) site from gbl and access token are the same and 3) ensure the site exists on filesystem if (empty($site) || empty($gbl::$SITE) || preg_match('/[^A-Za-z0-9\\-.]/', $gbl::$SITE) || ($site !== $gbl::$SITE) || !file_exists(__DIR__ . '/../sites/' . $gbl::$SITE)) { - error_log("OpenEMR Error - api site error, so forced exit"); + $logger->error("OpenEMR Error - api site error, so forced exit"); http_response_code(400); exit(); } @@ -86,7 +90,7 @@ if (!empty($_SERVER['HTTP_APICSRFTOKEN'])) { $tokenId = $attributes['oauth_access_token_id']; // ensure user uuid and token id are populated if (empty($userId) || empty($tokenId)) { - error_log("OpenEMR Error - userid or tokenid not available, so forced exit"); + $logger->error("OpenEMR Error - userid or tokenid not available, so forced exit"); http_response_code(400); exit(); } @@ -110,26 +114,30 @@ if ($isLocalApi) { $csrfFail = false; if (empty($_SERVER['HTTP_APICSRFTOKEN'])) { - error_log("OpenEMR Error: internal api failed because csrf token not received"); + $logger->error("OpenEMR Error: internal api failed because csrf token not received"); $csrfFail = true; } if ((!$csrfFail) && (!CsrfUtils::verifyCsrfToken($_SERVER['HTTP_APICSRFTOKEN'], 'api'))) { - error_log("OpenEMR Error: internal api failed because csrf token did not match"); + $logger->error("OpenEMR Error: internal api failed because csrf token did not match"); $csrfFail = true; } if ($csrfFail) { + $logger->error("dispatch.php CSRF failed", ["resource" => $resource]); http_response_code(401); exit(); } } elseif ($skipApiAuth) { + $logger->debug("dispatch.php skipping api auth"); // For endpoints that do not require auth, such as the capability statement } else { + $logger->debug("dispatch.php authenticating user"); // verify that user tokens haven't been revoked. // this is done by verifying the user is trusted with active auth session. $isTrusted = $gbl::isTrustedUser($attributes["oauth_client_id"], $attributes["oauth_user_id"]); if ($isTrusted instanceof ResponseInterface) { + $logger->debug("dispatch.php oauth2 inactive user api attempt"); // user is not logged on to server with an active session. // too me this is easier than revoking tokens or using phantom tokens. // give a 400(unsure here, could be a 401) so client can redirect to server. @@ -142,6 +150,7 @@ if ($isLocalApi) { // authenticate the token if (!$gbl->authenticateUserToken($tokenId, $userId)) { + $logger->error("dispatch.php api call with invalid token"); $gbl::destroySession(); http_response_code(401); exit(); @@ -152,14 +161,14 @@ if ($isLocalApi) { $userRole = $uuidToUser->getUserRole(); if (empty($user)) { // unable to identify the users user role - error_log("OpenEMR Error - api user account could not be identified, so forced exit"); + $logger->error("OpenEMR Error - api user account could not be identified, so forced exit"); $gbl::destroySession(); http_response_code(400); exit(); } if (empty($userRole)) { // unable to identify the users user role - error_log("OpenEMR Error - api user role for user could not be identified, so forced exit"); + $logger->error("OpenEMR Error - api user role for user could not be identified, so forced exit"); $gbl::destroySession(); http_response_code(400); exit(); @@ -169,11 +178,13 @@ if ($isLocalApi) { // users has access to oemr and fhir // patient has access to port and pofh if ($userRole == 'users' && ($gbl::is_api_request($resource) || $gbl::is_fhir_request($resource))) { + $logger->debug("dispatch.php valid role and user has access to api/fhir resource", ['resource' => $resource]); // good to go } elseif ($userRole == 'patient' && ($gbl::is_portal_request($resource) || $gbl::is_portal_fhir_request($resource))) { + $logger->debug("dispatch.php valid role and patient has access portal resource", ['resource' => $resource]); // good to go } else { - error_log("OpenEMR Error: api failed because user role does not have access to the resource"); + $logger->error("OpenEMR Error: api failed because user role does not have access to the resource"); $gbl::destroySession(); http_response_code(401); exit(); @@ -185,7 +196,7 @@ if ($isLocalApi) { $_SESSION['authProvider'] = sqlQueryNoLog("SELECT `name` FROM `groups` WHERE `user` = ?", [$_SESSION['authUser']])['name'] ?? null; if (empty($_SESSION['authUser']) || empty($_SESSION['authUserID']) || empty($_SESSION['authProvider'])) { // this should never happen - error_log("OpenEMR Error: api failed because unable to set critical users session variables"); + $logger->error("OpenEMR Error: api failed because unable to set critical users session variables"); $gbl::destroySession(); http_response_code(401); exit(); @@ -195,14 +206,14 @@ if ($isLocalApi) { $_SESSION['puuid'] = $user['uuid'] ?? null; if (empty($_SESSION['pid']) || empty($_SESSION['puuid'])) { // this should never happen - error_log("OpenEMR Error: api failed because unable to set critical patient session variables"); + $logger->error("OpenEMR Error: api failed because unable to set critical patient session variables"); $gbl::destroySession(); http_response_code(401); exit(); } } else { // this user role is not supported - error_log("OpenEMR Error - api user role that was provided is not supported, so forced exit"); + $logger->error("OpenEMR Error - api user role that was provided is not supported, so forced exit"); $gbl::destroySession(); http_response_code(400); exit(); @@ -223,6 +234,7 @@ $gbl::$PORTAL_FHIR_ROUTE_MAP = $restApiCreateEvent->getPortalFHIRRouteMap(); if ($gbl::is_fhir_request($resource)) { if (!$GLOBALS['rest_fhir_api'] && !$isLocalApi) { // if the external fhir api is turned off and this is not a local api call, then exit + $logger->error("dispatch.php attempted to access resource with FHIR api turned off ", ['resource' => $resource]); $gbl::destroySession(); http_response_code(501); exit(); @@ -231,6 +243,7 @@ if ($gbl::is_fhir_request($resource)) { $routes = $gbl::$FHIR_ROUTE_MAP; } elseif ($gbl::is_portal_request($resource)) { if (!$GLOBALS['rest_portal_api'] && !$isLocalApi) { + $logger->error("dispatch.php attempted to access resource with portal api turned off ", ['resource' => $resource]); // if the external portal api is turned off and this is not a local api call, then exit $gbl::destroySession(); http_response_code(501); @@ -240,6 +253,10 @@ if ($gbl::is_fhir_request($resource)) { $routes = $gbl::$PORTAL_ROUTE_MAP; } elseif ($gbl::is_portal_fhir_request($resource)) { if (!$GLOBALS['rest_portal_fhir_api'] && !$isLocalApi) { + $logger->error( + "dispatch.php attempted to access resource with portal FHIR api turned off ", + ['resource' => $resource] + ); // if the external portal fhir api is turned off and this is not a local api call, then exit $gbl::destroySession(); http_response_code(501); @@ -249,6 +266,10 @@ if ($gbl::is_fhir_request($resource)) { $routes = $gbl::$PORTAL_FHIR_ROUTE_MAP; } elseif ($gbl::is_api_request($resource)) { if (!$GLOBALS['rest_api'] && !$isLocalApi) { + $logger->error( + "dispatch.php attempted to access resource with REST api turned off ", + ['resource' => $resource] + ); // if the external api is turned off and this is not a local api call, then exit $gbl::destroySession(); http_response_code(501); @@ -257,6 +278,8 @@ if ($gbl::is_fhir_request($resource)) { $_SESSION['api'] = 'oemr'; $routes = $gbl::$ROUTE_MAP; } else { + $logger->error("dispatch.php invalid access to resource", ['resource' => $resource]); + // somebody is up to no good if (!$isLocalApi) { $gbl::destroySession(); @@ -280,5 +303,6 @@ if (!$isLocalApi) { } // prevent 200 if route doesn't exist if (!$hasRoute) { + $logger->debug("dispatch.php no route found for resource", ['resource' => $resource]); http_response_code(404); } diff --git a/composer.json b/composer.json index 68a3681a4..cdcc9020c 100644 --- a/composer.json +++ b/composer.json @@ -61,7 +61,9 @@ "steverhoades/oauth2-openid-connect-server": "1.2", "nyholm/psr7": "1.3.2", "nyholm/psr7-server": "1.0.1", - "lcobucci/jwt": "dev-34php8 as 3.4.9" + "lcobucci/jwt": "dev-34php8 as 3.4.9", + "psr/log": "1.1.3", + "monolog/monolog": "2.1.1" }, "config": { "platform": { diff --git a/composer.lock b/composer.lock index 98804786c..2b52ff239 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fe517420b22c900c838c11a9f5efce4a", + "content-hash": "6d577581f7449ddaaaf09130ec170dcc", "packages": [ { "name": "academe/authorizenet-objects", @@ -3980,6 +3980,101 @@ "time": "2020-03-18T17:49:59+00:00" }, { + "name": "monolog/monolog", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "f9eee5cec93dfb313a38b6b288741e84e53f02d5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/f9eee5cec93dfb313a38b6b288741e84e53f02d5", + "reference": "f9eee5cec93dfb313a38b6b288741e84e53f02d5", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/log": "^1.0.1" + }, + "provide": { + "psr/log-implementation": "1.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^2.4.9 || ^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^6.0", + "graylog2/gelf-php": "^1.4.2", + "php-amqplib/php-amqplib": "~2.4", + "php-console/php-console": "^3.1.3", + "php-parallel-lint/php-parallel-lint": "^1.0", + "phpspec/prophecy": "^1.6.1", + "phpunit/phpunit": "^8.5", + "predis/predis": "^1.1", + "rollbar/rollbar": "^1.3", + "ruflin/elastica": ">=0.90 <3.0", + "swiftmailer/swiftmailer": "^5.3|^6.0" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "php-console/php-console": "Allow sending log messages to Google Chrome", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "http://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/2.1.1" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2020-07-23T08:41:23+00:00" + }, + { "name": "mpdf/mpdf", "version": "v8.0.7", "source": { diff --git a/interface/patient_file/summary/demographics.php b/interface/patient_file/summary/demographics.php index 392e71738..9eb034083 100644 --- a/interface/patient_file/summary/demographics.php +++ b/interface/patient_file/summary/demographics.php @@ -33,6 +33,8 @@ use OpenEMR\Common\Csrf\CsrfUtils; use OpenEMR\Common\Session\SessionUtil; use OpenEMR\Core\Header; use OpenEMR\Events\PatientDemographics\ViewEvent; +use OpenEMR\Events\PatientDemographics\RenderEvent; +use OpenEMR\FHIR\SMART\SmartLaunchController; use OpenEMR\Menu\PatientMenuRole; use OpenEMR\OeUI\OemrUI; use OpenEMR\Reminder\BirthdayReminder; @@ -47,6 +49,12 @@ if (isset($_GET['set_pid'])) { } } +// Note: it would eventually be a good idea to move this into +// it's own module that people can remove / add if they don't +// want smart support in their system. +$smartLaunchController = new SMARTLaunchController($GLOBALS["kernel"]->getEventDispatcher()); +$smartLaunchController->registerContextEvents(); + $active_reminders = false; $all_allergy_alerts = false; if ($GLOBALS['enable_cdr']) { @@ -821,7 +829,10 @@ $oemr_ui = new OemrUI($arrOeUiSettings); - + getEventDispatcher()->dispatch(RenderEvent::EVENT_SECTION_LIST_RENDER_BEFORE, new RenderEvent($pid), 10); + ?>
getEventDispatcher()->dispatch(RenderEvent::EVENT_SECTION_LIST_RENDER_AFTER, new RenderEvent($pid), 10); // This generates a section similar to Vitals for each LBF form that // supports charting. The form ID is used as the "widget label". // diff --git a/interface/smart/register-app.php b/interface/smart/register-app.php new file mode 100644 index 000000000..179be4d17 --- /dev/null +++ b/interface/smart/register-app.php @@ -0,0 +1,159 @@ + + * @author Brady Miller + * @author Kevin Yeh + * @author Scott Wakefield + * @author ViCarePlus + * @author Julia Longtin + * @author cfapress + * @author markleeds + * @author Tyler Wrenn + * @author Stephen Nielson + * @copyright Copyright (c) 2019 Brady Miller + * @copyright Copyright (c) 2020 Tyler Wrenn + * @copyright Copyright (c) 2020 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +use OpenEMR\Core\Header; +use OpenEMR\RestControllers\AuthorizationController; +use OpenEMR\Services\FacilityService; + +// not sure if we need the site id or not... +$ignoreAuth = true; +require_once("../globals.php"); + +// This code allows configurable positioning in the login page +$loginrow = "row login-row align-items-center m-5"; + +if ($GLOBALS['login_page_layout'] == 'left') { + $logoarea = "col-md-6 login-bg-left py-3 px-5 py-md-login order-1 order-md-2"; + $formarea = "col-md-6 p-5 login-area-left order-2 order-md-1"; +} else if ($GLOBALS['login_page_layout'] == 'right') { + $logoarea = "col-md-6 login-bg-right py-3 px-5 py-md-login order-1 order-md-1"; + $formarea = "col-md-6 p-5 login-area-right order-2 order-md-2"; +} else { + $logoarea = "col-12 login-bg-center py-3 px-5 order-1"; + $formarea = "col-12 p-5 login-area-center order-2"; + $loginrow = "row login-row login-row-center align-items-center"; +} + +// TODO: adunsulag there's gotta be a better way for this url... +$fhirRegisterURL = AuthorizationController::getAuthBaseFullURL() . AuthorizationController::getRegistrationPath(); + +?> + + + + + <?php echo xlt('OpenEMR App Registration'); ?> + + + + +
+
+
+

+
+ + +
+
+ + +
+ + +
+
+ + +
+ +
+ +
+ + + +

+
+ +
+ + + diff --git a/library/dialog.js b/library/dialog.js index b6f24028b..98afc404d 100644 --- a/library/dialog.js +++ b/library/dialog.js @@ -426,6 +426,7 @@ function dlgopen(url, winname, width, height, forceNewWindow, title, opts) { sizeHeight: 'auto', // 'full' will use as much height as allowed // use is onClosed: fnName ... args not supported however, onClosed: 'reload' is auto defined and requires no function to be created. onClosed: false, + allowExternal: false, // allow a dialog window to a URL that is external to the current url callBack: false, // use {call: 'functionName, args: args, args} if known or use dlgclose. resolvePromiseOn: '' // this may be useful values are init, shown, show, confirm, alert and closed which coincide with dialog events. }; @@ -448,6 +449,12 @@ function dlgopen(url, winname, width, height, forceNewWindow, title, opts) { if (url) { if (url[0] === "/") { fullURL = url + } else if (opts.allowExternal === true) { + var checkUrl = new URL(url); + // we only allow http & https protocols to be launched + if (checkUrl.protocol === "http:" || checkUrl.protocol == "https:") { + fullURL = url; + } } else { fullURL = window.location.href.substr(0, window.location.href.lastIndexOf("/") + 1) + url; } diff --git a/oauth2/authorize.php b/oauth2/authorize.php index a4585c2f4..cadffc3f0 100644 --- a/oauth2/authorize.php +++ b/oauth2/authorize.php @@ -30,11 +30,14 @@ $ignoreAuth = true; require_once __DIR__ . '/../interface/globals.php'; use OpenEMR\Common\Csrf\CsrfUtils; +use OpenEMR\Common\Logging\SystemLogger; use OpenEMR\Common\Session\SessionUtil; use OpenEMR\RestControllers\AuthorizationController; +$logger = SystemLogger::instance(); // exit if api is not turned on if (empty($GLOBALS['rest_api']) && empty($GLOBALS['rest_fhir_api']) && empty($GLOBALS['rest_portal_api']) && empty($GLOBALS['rest_portal_fhir_api'])) { + $logger->debug("api disabled exiting call"); SessionUtil::oauthSessionCookieDestroy(); http_response_code(404); exit; @@ -43,7 +46,7 @@ if (empty($GLOBALS['rest_api']) && empty($GLOBALS['rest_fhir_api']) && empty($GL // ensure 1) sane site 2) site from gbl and globals are the same and 3) ensure the site exists on filesystem if (empty($gbl::$SITE) || empty($_SESSION['site_id']) || preg_match('/[^A-Za-z0-9\\-.]/', $gbl::$SITE) || ($gbl::$SITE != $_SESSION['site_id']) || !file_exists($GLOBALS['OE_SITES_BASE'] . '/' . $_SESSION['site_id'])) { // error collecting site - error_log("OpenEMR error - oauth2 error since unable to properly collect site, so forced exit"); + $logger->error("OpenEMR error - oauth2 error since unable to properly collect site, so forced exit"); SessionUtil::oauthSessionCookieDestroy(); http_response_code(400); exit; @@ -56,6 +59,13 @@ if (empty($_SESSION['csrf_private_key'])) { } $end_point = $gbl::getRequestEndPoint(); +$logger->debug("oauth2 request received", ["endpoint" => $end_point]); + +// let's quickly be able to enable our CORS at the PHP level. +header("Access-Control-Allow-Credentials: true"); +header("Access-Control-Allow-Headers: origin, authorization, accept, content-type, x-requested-with"); +header("Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS"); +header("Access-Control-Allow-Origin: *"); $authServer = new AuthorizationController(); diff --git a/public/smart-styles/smart-light.json b/public/smart-styles/smart-light.json new file mode 100644 index 000000000..0b3d55ff8 --- /dev/null +++ b/public/smart-styles/smart-light.json @@ -0,0 +1,13 @@ +{ + "color_background": "#f8f9fa", + "color_error": "#9e2d2d", + "color_highlight": "#69b5ce", + "color_modal_backdrop": "", + "color_success": "#498e49", + "color_text": "#000", + "dim_border_radius": "6px", + "dim_font_size": "13px", + "dim_spacing_size": "20px", + "font_family_body": "'Lato','Helvetica',sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol','Noto Color Emoji'", + "font_family_heading": "'Lato','Helvetica',sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol','Noto Color Emoji'" +} \ No newline at end of file diff --git a/src/Common/Auth/OpenIDConnect/Entities/ClientEntity.php b/src/Common/Auth/OpenIDConnect/Entities/ClientEntity.php index 6a3e52fbd..08ba147fd 100644 --- a/src/Common/Auth/OpenIDConnect/Entities/ClientEntity.php +++ b/src/Common/Auth/OpenIDConnect/Entities/ClientEntity.php @@ -23,6 +23,12 @@ class ClientEntity implements ClientEntityInterface protected $userId; protected $clientRole; + protected $scopes; + + public function __construct() + { + $this->scopes = []; + } public function setName($name): void { @@ -58,4 +64,34 @@ class ClientEntity implements ClientEntityInterface { return $this->clientRole; } + + public function getScopes() + { + return $this->scopes; + } + public function setScopes($scopes) + { + // clear out the scopes if our scopes are empty + if (empty($scopes)) { + $this->scopes = []; + return; + } + + if (is_string($scopes)) { + $scopes = explode(" ", $scopes); + } else if (!is_array($scopes)) { + throw new \InvalidArgumentException("scopes parameter must be a valid array or string"); + } + $this->scopes = $scopes; + } + + /** + * Checks if a given entity + * @param $scope + * @return bool + */ + public function hasScope($scope) + { + return array_search($scope, $this->scopes) !== false; + } } diff --git a/src/Common/Auth/OpenIDConnect/IdTokenSMARTResponse.php b/src/Common/Auth/OpenIDConnect/IdTokenSMARTResponse.php new file mode 100644 index 000000000..8d9ae456e --- /dev/null +++ b/src/Common/Auth/OpenIDConnect/IdTokenSMARTResponse.php @@ -0,0 +1,137 @@ + + * @copyright Copyright (c) 2020 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Common\Auth\OpenIDConnect; + +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use League\OAuth2\Server\Exception\OAuthServerException; +use OpenEMR\Common\Logging\SystemLogger; +use OpenEMR\FHIR\SMART\SmartLaunchController; +use OpenEMR\FHIR\SMART\SMARTLaunchToken; +use OpenEMR\Services\PatientService; +use OpenIDConnectServer\ClaimExtractor; +use OpenIDConnectServer\IdTokenResponse; +use OpenIDConnectServer\Repositories\IdentityProviderInterface; +use Psr\Log\LoggerInterface; + +class IdTokenSMARTResponse extends IdTokenResponse +{ + /** + * @var LoggerInterface + */ + private $logger; + + public function __construct( + IdentityProviderInterface $identityProvider, + ClaimExtractor $claimExtractor + ) { + $this->logger = SystemLogger::instance(); + parent::__construct($identityProvider, $claimExtractor); + } + + protected function getExtraParams(AccessTokenEntityInterface $accessToken) + { + $extraParams = parent::getExtraParams($accessToken); + $this->logger->debug("IdTokenSMARTResponse->getExtraParams() params from parent ", ["params" => $extraParams]); + + if ($this->isStandaloneLaunchPatientRequest($accessToken->getScopes())) { + // patient id that is currently selected in the session. + if (!empty($_SESSION['pid'])) { + $extraParams['patient'] = $_SESSION['pid']; + $extraParams['need_patient_banner'] = true; + $extraParams['smart_style_url'] = $this->getSmartStyleURL(); + } else { + throw new OAuthServerException("launch/patient scope requested but patient 'pid' was not present in session", 0, 'invalid_patient_context'); + } + } else if ($this->isLaunchRequest($accessToken->getScopes())) { + $this->logger->debug("launch scope requested"); + if (!empty($_SESSION['launch'])) { + $this->logger->debug("IdTokenSMARTResponse->getExtraParams() launch set in session", ['launch' => $_SESSION['launch']]); + // this is where the launch context is deserialized and we extract any SMART context state we wanted to + // pass on as part of the EHR request, we only have encounter and patient at this point + try { + // TODO: adunsulag do we want any kind of hmac signature to verify the request hasn't been + // tampered with? Not sure that it matters as the ACL's will verify that the app only has access + // to the data the currently authorized oauth2 user can access. + $launchToken = SMARTLaunchToken::deserializeToken($_SESSION['launch']); + $this->logger->debug("IdTokenSMARTResponse->getExtraParams() decoded launch context is", ['context' => $launchToken]); + + // we assume that if a patient is provided we are already displaying the patient + // we may in the future need to adjust the need_patient_banner depending on the 'intent' chosen. + if (!empty($launchToken->getPatient())) { + $extraParams['patient'] = $launchToken->getPatient(); + $extraParams['need_patient_banner'] = false; + } + if (!empty($launchToken->getEncounter())) { + $extraParams['encounter'] = $launchToken->getEncounter(); + } + if (!empty($launchToken->getIntent())) { + $extraParams['intent'] = $launchToken->getIntent(); + } + $extraParams['smart_style_url'] = $this->getSmartStyleURL(); + } catch (\Exception $ex) { + $this->logger->error("IdTokenSMARTResponse->getExtraParams() Failed to decode launch context parameter", ['error' => $ex->getMessage()]); + throw new OAuthServerException("Invalid launch parameter", 0, 'invalid_launch_context'); + } + } + } + + $this->logger->debug("IdTokenSMARTResponse->getExtraParams() final params", ["params" => $extraParams]); + return $extraParams; + } + + /** + * Needed for OpenEMR\FHIR\SMART\Capability::CONTEXT_STYLE support + * TODO: adunsulag do we want to try and read from the scss files and generate some kind of styles... + * Reading the SMART FHIR spec author forums so few app writers are actually using this at all, it seems like we + * can just use defaults without getting trying to load up based upon which skin we have, or using node & + * gulp to auto generate a skin. + */ + private function getSmartStyleURL() + { + return $GLOBALS['site_addr_oath'] . "/public/smart-styles/smart-light.json"; + } + + /** + * @param ScopeEntityInterface[] $scopes + * @return bool + */ + private function isLaunchRequest($scopes) + { + return $this->hasScope($scopes, 'launch'); + } + + /** + * @param ScopeEntityInterface[] $scopes + * @return bool + */ + private function isStandaloneLaunchPatientRequest($scopes) + { + return $this->hasScope($scopes, 'launch/patient'); + } + + private function hasScope($scopes, $searchScope) + { + // Verify scope and make sure openid exists. + $valid = false; + + foreach ($scopes as $scope) { + if ($scope->getIdentifier() == $searchScope) { + $valid = true; + break; + } + } + + return $valid; + } +} diff --git a/src/Common/Auth/OpenIDConnect/Repositories/ClientRepository.php b/src/Common/Auth/OpenIDConnect/Repositories/ClientRepository.php index b601dcced..7b61b9b0e 100644 --- a/src/Common/Auth/OpenIDConnect/Repositories/ClientRepository.php +++ b/src/Common/Auth/OpenIDConnect/Repositories/ClientRepository.php @@ -15,24 +15,60 @@ namespace OpenEMR\Common\Auth\OpenIDConnect\Repositories; use League\OAuth2\Server\Repositories\ClientRepositoryInterface; use OpenEMR\Common\Auth\OpenIDConnect\Entities\ClientEntity; use OpenEMR\Common\Crypto\CryptoGen; +use OpenEMR\Common\Logging\SystemLogger; +use Psr\Log\LoggerInterface; class ClientRepository implements ClientRepositoryInterface { + /** + * @var LoggerInterface + */ + private $logger; + + public function __construct() + { + $this->logger = SystemLogger::instance(); + } + + /** + * @return ClientEntity[] + */ + public function listClientEntities() + { + $clients = sqlStatementNoLog("Select * From oauth_clients"); + $list = []; + if (!empty($clients)) { + while ($client = $clients->FetchRow()) { + $list[] = $this->hydrateClientEntityFromArray($client); + } + } + return $list; + } + public function getClientEntity($clientIdentifier) { $clients = sqlQueryNoLog("Select * From oauth_clients Where client_id=?", array($clientIdentifier)); // Check if client is registered if ($clients === false) { + $this->logger->error( + "ClientRepository->getClientEntity() no client found for identifier ", + ["client" => $clientIdentifier] + ); return false; } - $client = new ClientEntity(); - $client->setIdentifier($clientIdentifier); - $client->setName($clients['client_name']); - $client->setRedirectUri($clients['redirect_uri']); - $client->setIsConfidential($clients['is_confidential']); - + $this->logger->debug( + "ClientRepository->getClientEntity() client found", + [ + "client" => [ + "client_name" => $clients['client_name'], + "redirect_uri" => $clients['redirect_uri'], + "is_confidential" => $clients['is_confidential'] + ] + ] + ); + $client = $this->hydrateClientEntityFromArray($clients); return $client; } @@ -43,6 +79,10 @@ class ClientRepository implements ClientRepositoryInterface // Check if client is registered if ($client === false) { + $this->logger->error( + "ClientRepository->validateClient() no client found for identifier ", + ["client" => $clientIdentifier] + ); return false; } @@ -61,4 +101,19 @@ class ClientRepository implements ClientRepositoryInterface return true; } } + + /** + * @param $clients + * @return ClientEntity + */ + private function hydrateClientEntityFromArray($client_record) + { + $client = new ClientEntity(); + $client->setIdentifier($client_record['client_id']); + $client->setName($client_record['client_name']); + $client->setRedirectUri($client_record['redirect_uri']); + $client->setIsConfidential($client_record['is_confidential']); + $client->setScopes($client_record['scope']); + return $client; + } } diff --git a/src/Common/Auth/OpenIDConnect/Repositories/ScopeRepository.php b/src/Common/Auth/OpenIDConnect/Repositories/ScopeRepository.php index c97d9a448..c45ddb456 100644 --- a/src/Common/Auth/OpenIDConnect/Repositories/ScopeRepository.php +++ b/src/Common/Auth/OpenIDConnect/Repositories/ScopeRepository.php @@ -15,12 +15,25 @@ namespace OpenEMR\Common\Auth\OpenIDConnect\Repositories; use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; use OpenEMR\Common\Auth\OpenIDConnect\Entities\ScopeEntity; +use OpenEMR\Common\Logging\SystemLogger; +use Psr\Log\LoggerInterface; class ScopeRepository implements ScopeRepositoryInterface { + /** + * @var LoggerInterface + */ + private $logger; + + public function __construct() + { + $this->logger = SystemLogger::instance(); + } + public function getScopeEntityByIdentifier($scopeIdentifier) { // I think we'll hardcode these. Not that many. + // TODO: adunsulag we need to merge these with what's in SmartConfigurationController and the oauth .well-known $scopes = [ 'openid' => [ 'description' => 'OpenId Connect', @@ -67,12 +80,25 @@ class ScopeRepository implements ScopeRepositoryInterface 'patient/*.read' => [ 'description' => 'Read only access to all information about a patient that currently exists and any information created in the future.', ], + 'patient/Patient.read' => [ + 'description' => 'Read only access a patient resource.', + ], + 'patient/Observation.read' => [ + 'description' => 'Read only access observation resources for a patient resource.', + ], + 'offline_access' => [ + 'description' => 'Long lived tokens for offline access', + ], 'launch/patient' => [ 'description' => 'Grant an external application the ability to launch and load your patient profile.', + ], + 'launch' => [ + 'description' => 'Grant an application the ability to launch and load your patient profile.', ] ]; if (array_key_exists($scopeIdentifier, $scopes) === false && stripos($scopeIdentifier, 'site:') === false) { + $this->logger->error("ScopeRepository->getScopeEntityByIdentifier() request access to invalid scope", ["scope" => $scopeIdentifier]); return null; } diff --git a/src/Common/Http/HttpRestRouteHandler.php b/src/Common/Http/HttpRestRouteHandler.php index dd3d3e2fd..c527ed77d 100644 --- a/src/Common/Http/HttpRestRouteHandler.php +++ b/src/Common/Http/HttpRestRouteHandler.php @@ -14,6 +14,8 @@ namespace OpenEMR\Common\Http; +use OpenEMR\Common\Logging\SystemLogger; + class HttpRestRouteHandler { public static function dispatch(&$routes, $route, $request_method, $return_method = 'standard') @@ -38,6 +40,7 @@ class HttpRestRouteHandler $matches = array(); if ($method === $request_method && preg_match($pattern, $route, $matches)) { array_shift($matches); + SystemLogger::instance()->debug("HttpRestRouteHandler->dispatch() dispatching route", ["route" => $routePath]); $hasRoute = true; $result = call_user_func_array($routeCallback, $matches); if ($return_method === 'standard') { diff --git a/src/Common/Logging/SystemLogger.php b/src/Common/Logging/SystemLogger.php new file mode 100644 index 000000000..aafd9ab23 --- /dev/null +++ b/src/Common/Logging/SystemLogger.php @@ -0,0 +1,210 @@ + + * @copyright Copyright (c) 2020 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ +class SystemLogger implements LoggerInterface +{ + use Singleton; + + /** + * @var LoggerInterface; + */ + private $logger; + + public function __construct() + { + /** + * We use mono + */ + $this->logger = new Logger('OpenEMR'); + $logLevel = Logger::WARNING; // change this if you want to filter what logs you see. + +// $facility = LOG_SYSLOG; // @see syslog constants https://www.php.net/manual/en/network.constants.php +// // Change the logger level to see what logs you want to log +// $this->logger->pushHandler(new Monolog\Handler\ErrorLogHandler('OpenEMR - ', $facility, $logLevel)); + $this->logger->pushHandler(new ErrorLogHandler(ErrorLogHandler::OPERATING_SYSTEM, $logLevel)); + } + + /** + * System is unusable. + * + * @param string $message + * @param array $context + * @return void + */ + public function emergency($message, array $context = array()) + { + $context = $this->escapeVariables($context); + $this->logger->emergency($message, $context); + } + + /** + * Action must be taken immediately. + * + * Example: Entire website down, database unavailable, etc. This should + * trigger the SMS alerts and wake you up. + * + * @param string $message + * @param array $context + * @return void + */ + public function alert($message, array $context = array()) + { + $context = $this->escapeVariables($context); + $this->logger->alert($message, $context); + } + + /** + * Critical conditions. + * + * Example: Application component unavailable, unexpected exception. + * + * @param string $message + * @param array $context + * @return void + */ + public function critical($message, array $context = array()) + { + $context = $this->escapeVariables($context); + $this->logger->critical($message, $context); + } + + /** + * Runtime errors that do not require immediate action but should typically + * be logged and monitored. + * + * @param string $message + * @param array $context + * @return void + */ + public function error($message, array $context = array()) + { + $context = $this->escapeVariables($context); + $this->logger->error($message, $context); + } + + /** + * Exceptional occurrences that are not errors. + * + * Example: Use of deprecated APIs, poor use of an API, undesirable things + * that are not necessarily wrong. + * + * @param string $message + * @param array $context + * @return void + */ + public function warning($message, array $context = array()) + { + $context = $this->escapeVariables($context); + $this->logger->warning($message, $context); + } + + /** + * Normal but significant events. + * + * @param string $message + * @param array $context + * @return void + */ + public function notice($message, array $context = array()) + { + $context = $this->escapeVariables($context); + $this->logger->notice($message, $context); + } + + /** + * Interesting events. + * + * Example: User logs in, SQL logs. + * + * @param string $message + * @param array $context + * @return void + */ + public function info($message, array $context = array()) + { + $context = $this->escapeVariables($context); + $this->logger->info($message, $context); + } + + /** + * Detailed debug information. + * + * @param string $message + * @param array $context + * @return void + */ + public function debug($message, array $context = array()) + { + $context = $this->escapeVariables($context); + $this->logger->debug($message, $context); + } + + /** + * Logs with an arbitrary level. + * + * @param mixed $level + * @param string $message + * @param array $context + * @return void + */ + public function log($level, $message, array $context = array()) + { + $context = $this->escapeVariables($context); + $this->logger->log($level, $message, $context); + } + + private function escapeVariables($dictionary, $recurseLimit = 0) + { + if ($recurseLimit > 25) { + return "Cannot escape further. Maximum nested limit reached"; + } + + // the inner library may already be safely escaping values, but we don't want to assume that + // so we go through and make sure we use the OpenEMR errorLogEscape to make sure nothing + // hits the log file that could be an attack vector + // if we have a different LogHandler this logic may need to be revisited. + $escapedDict = []; + foreach ($dictionary as $key => $value) { + $escapedKey = $this->escapeValue($key); + if (is_array($value)) { + $escapedDict[$key] = $this->escapeVariables($value, $recurseLimit + 1); + } else if (is_object($value)) { + try { + $object = json_encode($value); + $escapedDict[$escapedKey] = $this->escapeValue($object); + } catch (\Exception $error) { + error_log($error->getMessage()); + } + } else { + $escapedDict[$escapedKey] = $this->escapeValue($value); + } + } + return $escapedDict; + } + + /** + * Safely escape a single value that can be written out to a log file. + * @param $var + * @return string + */ + private function escapeValue($var) + { + return errorLogEscape($var); + } +} diff --git a/src/Events/PatientDemographics/RenderEvent.php b/src/Events/PatientDemographics/RenderEvent.php new file mode 100644 index 000000000..ceb687cb4 --- /dev/null +++ b/src/Events/PatientDemographics/RenderEvent.php @@ -0,0 +1,64 @@ + + * @copyright Copyright (c) 2019 Ken Chapple + */ +class RenderEvent extends Event +{ + /** + * This event occurs after a patient demographics section list has been rendered + * It allows event listeners to render additional functionality after a section + * list. + */ + const EVENT_SECTION_LIST_RENDER_BEFORE = 'patientDemographics.render.section.before'; + + /** + * This event occurs after a patient demographics section list has been rendered + * It allows event listeners to render additional functionality after a section + * list. + */ + const EVENT_SECTION_LIST_RENDER_AFTER = 'patientDemographics.render.section.after'; + + /** + * @var null|integer + * + * Represents the patient we are viewing in the patient demographics + */ + private $pid = null; + + /** + * constructor. + * + * @param integer $pid Patient Identifier + */ + public function __construct($pid) + { + $this->pid = $pid; + } + + /** + * @return int|null + * + * Get the patient identifier of the patient we're attempting to view + */ + public function getPid() + { + return $this->pid; + } +} diff --git a/src/FHIR/SMART/Capability.php b/src/FHIR/SMART/Capability.php new file mode 100644 index 000000000..5f6c55f9f --- /dev/null +++ b/src/FHIR/SMART/Capability.php @@ -0,0 +1,76 @@ + + * @copyright Copyright (c) 2020 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\FHIR\SMART; + +class Capability +{ + /** + * The SMART extension capabilites that our system supports + * @see http://hl7.org/fhir/smart-app-launch/conformance/index.html + * + * All of these capabilities for MU3 are required to be implemented before HIT certification + * can be complete. + * @see ONC final rule commentary https://www.federalregister.gov/d/2020-07419/p-1184 Accessed on December 9th 2020 + */ + const SUPPORTED_CAPABILITIES = [self::LAUNCH_EHR, self::CONTEXT_BANNER, self::CONTEXT_EHR_PATIENT + , self::CONTEXT_STYLE ]; + + // support for SMART’s EHR Launch mode + const LAUNCH_EHR = 'launch-ehr'; + + // support for SMART’s Standalone Launch mode + const LAUNCH_STANDALONE = 'launch-standalone'; + + // support for SMART’s public client profile (no client authentication) + const CLIENT_PUBLIC = 'client-public'; + + // support for SMART’s confidential client profile (symmetric client secret authentication) + const CLIENT_CONFIDENTIAL_SYMMETRIC = "client-confidential-symmetric"; + + // support for SMART’s OpenID Connect profile + const SSO_OPENID_CONNECTION = "sso-openid-connect"; + + // support for “need patient banner” launch context (conveyed via need_patient_banner token parameter) + const CONTEXT_BANNER = "context-banner"; + + // support for “SMART style URL” launch context (conveyed via smart_style_url token parameter) + // NOTE: context-style is marked in HL7 SMART as EXPERIMENTAL, so expect this to change in time + // HL7/SMART chat forum was a bit confused by ONC's decision to include this, so again expect + // to see this change. + // @see SMARTConfigurationController->getStyles() + const CONTEXT_STYLE = "context-style"; + + // support for patient-level launch context (requested by launch scope, conveyed via patient token parameter) + const CONTEXT_EHR_PATIENT = "context-ehr-patient"; + + // support for patient-level launch context (requested by launch scope, conveyed via encounter token parameter) + const CONTEXT_EHR_ENCOUNTER = "context-ehr-encounter"; + + // support for patient-level launch context (requested by launch/patient scope, conveyed via patient token parameter) + const CONTEXT_STANDALONE_PATIENT = "context-standalone-patient"; + // support for encounter-level launch context (requested by launch/encounter scope, conveyed via encounter token + const CONTEXT_STANDALONE_ENCOUNTER = "context-standalone-encounter"; + + const PERMISSION_ONLINE = "permission-online"; + + // support for refresh tokens (requested by offline_access scope) + const PERMISSION_OFFLINE = "permission-offline"; + + // support for patient-level scopes (e.g. patient/Observation.read) + const PERMISSION_PATIENT = "permission-patient"; + + // support for user-level scopes (e.g. user/Appointment.read) + const PERMISSION_USER = "permission-user"; +} diff --git a/src/FHIR/SMART/SMARTLaunchToken.php b/src/FHIR/SMART/SMARTLaunchToken.php new file mode 100644 index 000000000..f636adbbb --- /dev/null +++ b/src/FHIR/SMART/SMARTLaunchToken.php @@ -0,0 +1,138 @@ + + * @copyright Copyright (c) 2020 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\FHIR\SMART; + +use OpenEMR\Common\Uuid\UuidRegistry; + +class SMARTLaunchToken +{ + public const INTENT_PATIENT_DEMOGRAPHICS_DIALOG = 'patient.demographics.dialog'; + public const VALID_INTENTS = [self::INTENT_PATIENT_DEMOGRAPHICS_DIALOG]; + + private $patient; + private $intent; + private $encounter; + + public function __construct($patientUUID = null, $encounterUUID = null) + { + if (isset($patientUUID) && !is_string($patientUUID)) { + throw new \InvalidArgumentException("patientUUID must be a string"); + } + if (isset($encounterUUID) && !is_string($encounterUUID)) { + throw new \InvalidArgumentException("encounterUUID must be a string"); + } + $this->patient = $patientUUID; + $this->encounter = $encounterUUID; + } + + /** + * @return mixed + */ + public function getPatient() + { + return $this->patient; + } + + /** + * @param mixed $patient + */ + public function setPatient($patient): void + { + $this->patient = $patient; + } + + /** + * @return mixed + */ + public function getEncounter() + { + return $this->encounter; + } + + /** + * @param mixed $encounter + */ + public function setEncounter($encounter): void + { + $this->encounter = $encounter; + } + + /** + * @return mixed + */ + public function getIntent() + { + return $this->intent; + } + + /** + * @param mixed $intent + */ + public function setIntent($intent): void + { + $this->intent = $intent; + } + + public function serialize() + { + $context = []; + $encounter = $this->getEncounter(); + $patient = $this->getPatient(); + $intent = $this->getIntent(); + if (!empty($encounter)) { + $context['e'] = $encounter; + } + if (!empty($patient)) { + $context['p'] = $patient; + } + if (!empty($intent)) { + $context['i'] = $intent; + } + + // no security is really needed here... just need to be able to wrap + // the current context into some kind of opaque id that the app will pass to the server and we can then + // return to system + // TODO: adunsulag do we want a nonce here? don't think it will matter as user has to pass through oauth2 grant + // in order to get back the launch code. + $serialized = base64_encode(json_encode($context)); + return $serialized; + } + + public static function deserializeToken($serialized) + { + $token = new self(); + $token->deserialize($serialized); + return $token; + } + + public function deserialize($serialized) + { + $decoded = base64_decode($serialized); + // invalid json let it throw here + $context = json_decode($decoded, true); + if (!empty($context['p'])) { + $this->setPatient($context['p']); + } + if (!empty($context['e'])) { + $this->setEncounter($context['e']); + } + if (!empty($context['i']) && $this->isValidIntent($context['i'])) { + $this->setIntent($context['i']); + } + } + + public function isValidIntent($intent) + { + return array_search($intent, self::VALID_INTENTS) !== false; + } +} diff --git a/src/FHIR/SMART/SmartLaunchController.php b/src/FHIR/SMART/SmartLaunchController.php new file mode 100644 index 000000000..77d33b0f1 --- /dev/null +++ b/src/FHIR/SMART/SmartLaunchController.php @@ -0,0 +1,168 @@ + + * @copyright Copyright (c) 2020 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\FHIR\SMART; + +use OpenEMR\Common\Auth\OpenIDConnect\Repositories\ClientRepository; +use OpenEMR\Common\Uuid\UuidRegistry; +use OpenEMR\Events\PatientDemographics\RenderEvent; +use OpenEMR\RestControllers\AuthorizationController; +use OpenEMR\Services\PatientService; +use Symfony\Component\EventDispatcher\EventDispatcher; + +// not sure I really like this here... it seems like some of this +// should be encapsulated in a class that autoloading can reach. +require_once(__DIR__ . '/../../../_rest_config.php'); + +/** + * Class SmartLaunchController handles the display and launching of SMART apps from the user interface. + * @package OpenEMR\FHIR\SMART + */ +class SmartLaunchController +{ + /** + * @var EventDispatcher + */ + private $dispatcher; + + public function __construct(EventDispatcher $dispatcher = null) + { + $this->dispatcher = $dispatcher; + } + + public function registerContextEvents() + { + $this->dispatcher->addListener(RenderEvent::EVENT_SECTION_LIST_RENDER_AFTER, [$this, 'renderPatientSmartLaunchSection']); + } + + public function renderPatientSmartLaunchSection(RenderEvent $event) + { + $smartClients = $this->getSMARTClients(); + // TODO: adunsulag we would filter the clients based on their smart capability & scopes they could send... + $pid = $event->getPid(); + $patientService = new PatientService(); + // make sure we've created all of our missing UUIDs + (new UuidRegistry(['table_name' => 'patient_data']))->createMissingUuids(); + // going to work with string uuids + $puuid = UuidRegistry::uuidToString($patientService->getUuid($pid)); + ?> +
+ getLaunchCodeContext($puuid); + // TODO: adunsulag is there an redirect_uri that we can specify for the launch path?? The spec feels vague + // here... all the SMART apps we've seen appear to follow a 'launch.html' nomenclature but that doesn't + // appear to be required in the spec. + + $gbl = \RestConfig::GetInstance(); + // TODO: adunsulag surely we can centralize where this fhir API url is set? + // TODO: adunsulag what is wrong with these URL's? I'm having to hard code the issuer as I can't + // seem to get these URLs right. for some reason the $SITE is set to interface, we don't get 'apis' in there + // ROOT_URL appears to be empty.. just strange + // $issuer = $GLOBALS['site_addr_oath'] . $gbl::$SITE . $gbl::$ROOT_URL . "/fhir"; +// $issuer = $GLOBALS['site_addr_oath'] . "/apis/default/fhir"; + $issuer = $GLOBALS['site_addr_oath'] . $GLOBALS['web_root'] . '/apis/' . $_SESSION['site_id'] . "/fhir"; + $launchParams = "launch.html?launch=" . urlencode($launchCode) . "&iss=" . urlencode($issuer); + + expand_collapse_widget( + $widgetTitle, + $widgetLabel, + $widgetButtonLabel, + $widgetButtonLink, + $widgetButtonClass, + $linkMethod, + $bodyClass, + $widgetAuth, + $fixedWidth, + $forceExpandAlways + ); + ?> +
+
    + +
  • + + +
  • + + getName()); ?> +
  • + +
+
+
+ + + listClientEntities(); + $smartList = []; + foreach ($clientEntities as $client) { + // only clients with a registered 'launch' scope will show up as + // launchable inside EHR launch scope. + // TODO: adunsulag should these scopes be against a class constant? if we pull them from a db that won't + // work... + if ($client->hasScope("launch")) { + $smartList[] = $client; + } + } + return $smartList; + } + + private function getLaunchCodeContext($patientUUID, $encounterId = null) + { + $token = new SMARTLaunchToken($patientUUID, $encounterId); + $token->setIntent(SMARTLaunchToken::INTENT_PATIENT_DEMOGRAPHICS_DIALOG); + return $token->serialize(); + } +} \ No newline at end of file diff --git a/src/RestControllers/AuthorizationController.php b/src/RestControllers/AuthorizationController.php index 0a643f30b..6c90a6bad 100644 --- a/src/RestControllers/AuthorizationController.php +++ b/src/RestControllers/AuthorizationController.php @@ -37,6 +37,7 @@ use OpenEMR\Common\Auth\OpenIDConnect\Entities\ClientEntity; use OpenEMR\Common\Auth\OpenIDConnect\Entities\ScopeEntity; use OpenEMR\Common\Auth\OpenIDConnect\Entities\UserEntity; use OpenEMR\Common\Auth\OpenIDConnect\Grant\CustomPasswordGrant; +use OpenEMR\Common\Auth\OpenIDConnect\IdTokenSMARTResponse; use OpenEMR\Common\Auth\OpenIDConnect\Repositories\AccessTokenRepository; use OpenEMR\Common\Auth\OpenIDConnect\Repositories\AuthCodeRepository; use OpenEMR\Common\Auth\OpenIDConnect\Repositories\ClientRepository; @@ -46,6 +47,7 @@ use OpenEMR\Common\Auth\OpenIDConnect\Repositories\ScopeRepository; use OpenEMR\Common\Auth\OpenIDConnect\Repositories\UserRepository; use OpenEMR\Common\Crypto\CryptoGen; use OpenEMR\Common\Csrf\CsrfUtils; +use OpenEMR\Common\Logging\SystemLogger; use OpenEMR\Common\Session\SessionUtil; use OpenEMR\Common\Utils\RandomGenUtils; use OpenEMR\Common\Uuid\UuidRegistry; @@ -54,6 +56,7 @@ use OpenIDConnectServer\Entities\ClaimSetEntity; use OpenIDConnectServer\IdTokenResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Log\LoggerInterface; use RuntimeException; class AuthorizationController @@ -76,6 +79,11 @@ class AuthorizationController private $cryptoGen; private $userId; + /** + * @var LoggerInterface + */ + private $logger; + public function __construct($providerForm = true) { global $gbl; @@ -85,13 +93,14 @@ class AuthorizationController $this->supportedScopes = $gbl::supportedScopes(); // what would we be if we didn't have Globals... $this->authBaseUrl = $GLOBALS['webroot'] . '/oauth2/' . $_SESSION['site_id']; - // collect full url and issuing url by using 'site_addr_oath' global - $this->authBaseFullUrl = $GLOBALS['site_addr_oath'] . $this->authBaseUrl; + $this->authBaseFullUrl = self::getAuthBaseFullURL(); $this->authIssueFullUrl = $GLOBALS['site_addr_oath'] . $GLOBALS['webroot']; // used for session stash $this->authRequestSerial = $_SESSION['authRequestSerial'] ?? ''; // Create a crypto object that will be used for for encryption/decryption $this->cryptoGen = new CryptoGen(); + $this->logger = SystemLogger::instance(); + // encryption key $eKey = sqlQueryNoLog("SELECT `name`, `value` FROM `keys` WHERE `name` = 'oauth2key'"); if (!empty($eKey['name']) && ($eKey['name'] === 'oauth2key')) { @@ -237,6 +246,10 @@ class AuthorizationController 'request_uris' => null, 'response_types' => null, 'grant_types' => null, + // info on scope can be seen at + // OAUTH2 Dynamic Client Registration RFC 7591 Section 2 Page 9 + // @see https://tools.ietf.org/html/rfc7591#section-2 + 'scope' => null ); $client_id = $this->base64url_encode(RandomGenUtils::produceRandomBytes(32)); $reg_token = $this->base64url_encode(RandomGenUtils::produceRandomBytes(32)); @@ -354,13 +367,21 @@ class AuthorizationController $redirects = $info['redirect_uris']; $logout_redirect_uris = $info['post_logout_redirect_uris'] ?? null; $info['client_secret'] = $info['client_secret'] ?? null; // just to be sure empty is null; + // set our list of default scopes for the registration if our scope is empty + // This is how a client can set if they support SMART apps and other stuff by passing in the 'launch' + // scope to the dynamic client registration. + // per RFC 7591 @see https://tools.ietf.org/html/rfc7591#section-2 + // TODO: adunsulag do we need to reject the registration if there are certain scopes here we do not support + // TODO: adunsulag should we check these scopes against our '$this->supportedScopes'? + $info['scope'] = $info['scope'] ?? 'openid email phone address api:oemr api:fhir api:port api:pofh'; // encrypt the client secret if (!empty($info['client_secret'])) { $info['client_secret'] = $this->cryptoGen->encryptStandard($info['client_secret']); } + try { - $sql = "INSERT INTO `oauth_clients` (`client_id`, `client_role`, `client_name`, `client_secret`, `registration_token`, `registration_uri_path`, `register_date`, `revoke_date`, `contacts`, `redirect_uri`, `grant_types`, `scope`, `user_id`, `site_id`, `is_confidential`, `logout_redirect_uris`, `jwks_uri`, `jwks`, `initiate_login_uri`, `endorsements`, `policy_uri`, `tos_uri`) VALUES (?, ?, ?, ?, ?, ?, NOW(), NULL, ?, ?, 'authorization_code', 'openid email phone address api:oemr api:fhir api:port api:pofh', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + $sql = "INSERT INTO `oauth_clients` (`client_id`, `client_role`, `client_name`, `client_secret`, `registration_token`, `registration_uri_path`, `register_date`, `revoke_date`, `contacts`, `redirect_uri`, `grant_types`, `scope`, `user_id`, `site_id`, `is_confidential`, `logout_redirect_uris`, `jwks_uri`, `jwks`, `initiate_login_uri`, `endorsements`, `policy_uri`, `tos_uri`) VALUES (?, ?, ?, ?, ?, ?, NOW(), NULL, ?, ?, 'authorization_code', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; $i_vals = array( $clientId, 'users', @@ -370,6 +391,7 @@ class AuthorizationController $info['registration_client_uri_path'], $contacts, $redirects, + $info['scope'], $user, $site, $private, @@ -475,6 +497,7 @@ class AuthorizationController public function oauthAuthorizationFlow(): void { + $this->logger->debug("AuthorizationController->oauthAuthorizationFlow() starting authorization flow"); $response = $this->createServerResponse(); $request = $this->createServerRequest(); @@ -482,25 +505,35 @@ class AuthorizationController $_SESSION['nonce'] = $request->getQueryParams()['nonce']; } + $this->logger->debug("AuthorizationController->oauthAuthorizationFlow() request query params ", ["queryParams" => $request->getQueryParams()]); + $this->grantType = 'authorization_code'; $server = $this->getAuthorizationServer(); try { // Validate the HTTP request and return an AuthorizationRequest object. + $this->logger->debug("AuthorizationController->oauthAuthorizationFlow() attempting to validate auth request"); $authRequest = $server->validateAuthorizationRequest($request); $_SESSION['csrf'] = $authRequest->getState(); $_SESSION['scopes'] = $request->getQueryParams()['scope']; $_SESSION['client_id'] = $request->getQueryParams()['client_id']; + $_SESSION['launch'] = $request->getQueryParams()['launch']; + $this->logger->debug("AuthorizationController->oauthAuthorizationFlow() session updated", ['session' => $_SESSION]); + + $this->logger->debug("AuthorizationController->oauthAuthorizationFlow() auth request validated, csrf,scopes,client_id setup"); // If needed, serialize into a users session if ($this->providerForm) { - $this->serializeUserSession($authRequest); + $this->serializeUserSession($authRequest, $request); + $this->logger->debug("AuthorizationController->oauthAuthorizationFlow() redirecting to provider form"); // call our login then login calls authorize if approved by user header("Location: " . $this->authBaseUrl . "/provider/login", true, 301); exit; } } catch (OAuthServerException $exception) { + $this->logger->error("AuthorizationController->oauthAuthorizationFlow() OAuthServerException", ["message" => $exception->getMessage()]); SessionUtil::oauthSessionCookieDestroy(); $this->emitResponse($exception->generateHttpResponse($response)); } catch (Exception $exception) { + $this->logger->error("AuthorizationController->oauthAuthorizationFlow() Exception message: " . $exception->getMessage()); SessionUtil::oauthSessionCookieDestroy(); $body = $response->getBody(); $body->write($exception->getMessage()); @@ -531,7 +564,9 @@ class AuthorizationController } // OpenID Connect Response Type - $responseType = new IdTokenResponse(new IdentityRepository(), new ClaimExtractor($customClaim)); + $this->logger->debug("AuthorizationController->getAuthorizationServer() creating server"); + $responseType = new IdTokenSMARTResponse(new IdentityRepository(), new ClaimExtractor($customClaim)); + $authServer = new AuthorizationServer( new ClientRepository(), new AccessTokenRepository(), @@ -543,6 +578,7 @@ class AuthorizationController if (empty($this->grantType)) { $this->grantType = 'authorization_code'; } + $this->logger->debug("AuthorizationController->getAuthorizationServer() grantType is " . $this->grantType); if ($this->grantType === 'authorization_code') { $grant = new AuthCodeGrant( new AuthCodeRepository(), @@ -583,11 +619,13 @@ class AuthorizationController ); } + $this->logger->debug("AuthorizationController->getAuthorizationServer() authServer created"); return $authServer; } - private function serializeUserSession($authRequest): void + private function serializeUserSession($authRequest, ServerRequestInterface $httpRequest): void { + $launchParam = isset($httpRequest->getQueryParams()['launch']) ? $httpRequest->getQueryParams()['launch'] : null; // keeping somewhat granular try { $scopes = $authRequest->getScopes(); @@ -620,6 +658,7 @@ class AuthorizationController $response = $this->createServerResponse(); if (empty($_POST['username']) && empty($_POST['password'])) { + $this->logger->debug("AuthorizationController->userLogin() presenting blank login form"); $oauthLogin = true; $redirect = $this->authBaseUrl . "/login"; require_once(__DIR__ . "/../../oauth2/provider/login.php"); @@ -628,6 +667,7 @@ class AuthorizationController $continueLogin = false; if (isset($_POST['user_role'])) { if (!CsrfUtils::verifyCsrfToken($_POST["csrf_token_form"], 'oauth2')) { + $this->logger->error("AuthorizationController->userLogin() Invalid CSRF token"); CsrfUtils::csrfNotVerified(false, true, false); unset($_POST['username'], $_POST['password']); $invalid = "Sorry. Invalid CSRF!"; // todo: display error @@ -636,16 +676,21 @@ class AuthorizationController require_once(__DIR__ . "/../../oauth2/provider/login.php"); exit(); } else { + $this->logger->debug("AuthorizationController->userLogin() verifying login information"); $continueLogin = $this->verifyLogin($_POST['username'], $_POST['password'], $_POST['email'], $_POST['user_role']); + $this->logger->debug("AuthorizationController->userLogin() verifyLogin result", ["continueLogin" => $continueLogin]); } } if (!$continueLogin) { + $this->logger->debug("AuthorizationController->userLogin() login invalid, presenting login form"); $invalid = "Sorry, Invalid!"; // todo: display error $oauthLogin = true; $redirect = $this->authBaseUrl . "/login"; require_once(__DIR__ . "/../../oauth2/provider/login.php"); exit(); + } else { + $this->logger->debug("AuthorizationController->userLogin() login valid, continuing oauth process"); } //Require MFA if turn on, currently support only TOTP method @@ -694,14 +739,18 @@ class AuthorizationController $auth = new AuthUtils($type); $is_true = $auth->confirmPassword($username, $password, $email); if (!$is_true) { + $this->logger->debug("AuthorizationController->verifyLogin() login attempt failed", ['username' => $username]); return false; } if ($this->userId = $auth->getUserId()) { $_SESSION['user_id'] = $this->getUserUuid($this->userId, 'users'); + $this->logger->debug("AuthorizationController->verifyLogin() user login", ['pid' => $_SESSION['pid']]); return true; } if ($id = $auth->getPatientId()) { $_SESSION['user_id'] = $this->getUserUuid($id, 'patient'); + $this->logger->debug("AuthorizationController->verifyLogin() patient login", ['pid' => $_SESSION['user_id']]); + $_SESSION['pid'] = $_SESSION['user_id']; return true; } @@ -730,6 +779,7 @@ class AuthorizationController public function authorizeUser(): void { + $this->logger->debug("AuthorizationController->authorizeUser() starting authorization"); $response = $this->createServerResponse(); $authRequest = $this->deserializeUserSession(); try { @@ -766,10 +816,12 @@ class AuthorizationController } } // Return the HTTP redirect response. Redirect is to client callback. + $this->logger->debug("AuthorizationController->authorizeUser() sending server response"); $this->emitResponse($result); SessionUtil::oauthSessionCookieDestroy(); exit; } catch (Exception $exception) { + $this->logger->error("AuthorizationController->authorizeUser() Exception thrown", ["message" => $exception->getMessage()]); SessionUtil::oauthSessionCookieDestroy(); $body = $response->getBody(); $body->write($exception->getMessage()); @@ -814,6 +866,7 @@ class AuthorizationController public function oauthAuthorizeToken(): void { + $this->logger->debug("AuthorizationController->oauthAuthorizeToken() starting request"); $response = $this->createServerResponse(); $request = $this->createServerRequest(); @@ -859,9 +912,17 @@ class AuthorizationController } SessionUtil::oauthSessionCookieDestroy(); } catch (OAuthServerException $exception) { + $this->logger->error( + "AuthorizationController->oauthAuthorizeToken() OAuthServerException occurred", + ["message" => $exception->getMessage()] + ); SessionUtil::oauthSessionCookieDestroy(); $this->emitResponse($exception->generateHttpResponse($response)); } catch (Exception $exception) { + $this->logger->error( + "AuthorizationController->oauthAuthorizeToken() Exception occurred", + ["message" => $exception->getMessage()] + ); SessionUtil::oauthSessionCookieDestroy(); $body = $response->getBody(); $body->write($exception->getMessage()); @@ -1084,4 +1145,103 @@ class AuthorizationController $this->emitResponse($response->withStatus(200)->withBody($body)); exit(); } + + /** + * Returns the authentication server token Url endpoint + * @return string + */ + public function getTokenUrl() + { + return $this->authBaseFullUrl . self::getTokenPath(); + } + + /** + * Returns the path prefix that the token authorization endpoint is on. + * @return string + */ + public static function getTokenPath() + { + return "/token"; + } + + /** + * Returns the authentication server manage url + * @return string + */ + public function getManageUrl() + { + return $this->authBaseFullUrl . self::getManagePath(); + } + + /** + * Returns the path prefix that the manage token authorization endpoint is on. + * @return string + */ + public static function getManagePath() + { + return "/manage"; + } + + /** + * Returns the authentication server authorization url to use for oauth authentication + * @return string + */ + public function getAuthorizeUrl() + { + return $this->authBaseFullUrl . self::getAuthorizePath(); + } + + /** + * Returns the path prefix that the authorization endpoint is on. + * @return string + */ + public static function getAuthorizePath() + { + return "/authorize"; + } + + /** + * Returns the authentication server registration url to use for client app / api registration + * @return string + */ + public function getRegistrationUrl() + { + return $this->authBaseFullUrl . self::getRegistrationPath(); + } + + /** + * Returns the path prefix that the registration endpoint is on. + * @return string + */ + public static function getRegistrationPath() + { + return "/registration"; + } + + /** + * Returns the authentication server introspection url to use for checking tokens + * @return string + */ + public function getIntrospectionUrl() + { + return $this->authBaseFullUrl . self::getIntrospectionPath(); + } + + /** + * Returns the path prefix that the introspection endpoint is on. + * @return string + */ + public static function getIntrospectionPath() + { + return "/introspect"; + } + + + public static function getAuthBaseFullURL() + { + $baseUrl = $GLOBALS['webroot'] . '/oauth2/' . $_SESSION['site_id']; + // collect full url and issuing url by using 'site_addr_oath' global + $authBaseFullURL = $GLOBALS['site_addr_oath'] . $baseUrl; + return $authBaseFullURL; + } } diff --git a/src/RestControllers/FHIR/FhirMetaDataRestController.php b/src/RestControllers/FHIR/FhirMetaDataRestController.php index 31e7eae72..be9129743 100644 --- a/src/RestControllers/FHIR/FhirMetaDataRestController.php +++ b/src/RestControllers/FHIR/FhirMetaDataRestController.php @@ -10,12 +10,14 @@ namespace OpenEMR\RestControllers\FHIR; +use OpenEMR\FHIR\R4\FHIRElement\FHIRCodeableConcept; +use OpenEMR\FHIR\R4\FHIRElement\FHIRCoding; +use OpenEMR\FHIR\R4\FHIRElement\FHIRExtension; +use OpenEMR\FHIR\R4\FHIRResource\FHIRCapabilityStatement\FHIRCapabilityStatementSecurity; +use OpenEMR\FHIR\SMART\Capability; +use OpenEMR\RestControllers\AuthorizationController; use OpenEMR\Services\FHIR\FhirResourcesService; -use OpenEMR\Services\FHIR\FhirPatientService; use OpenEMR\Services\FHIR\FhirValidationService; -use OpenEMR\RestControllers\RestControllerHelper; -use OpenEMR\FHIR\R4\FHIRResource\FHIRBundle\FHIRBundleEntry; -use OpenEMR\Validators\ProcessingResult; use OpenEMR\FHIR\R4\FHIRDomainResource\FHIRCapabilityStatement; use OpenEMR\FHIR\R4\FHIRElement\FHIRDateTime; use OpenEMR\FHIR\R4\FHIRResource\FHIRCapabilityStatement\FHIRCapabilityStatementRest; @@ -33,7 +35,6 @@ require_once(__DIR__ . '/../../../_rest_config.php'); */ class FhirMetaDataRestController { - private $fhirPatientService; private $fhirService; private $fhirValidate; @@ -130,7 +131,7 @@ class FhirMetaDataRestController } $restItem = array( "resource" => $resources, - "mode" => "server" + "mode" => "server", ); return $restItem; } @@ -159,6 +160,7 @@ class FhirMetaDataRestController $capabilityStatement->setDate($dateTime); $restJSON = $this->getCapabilityRESTJSON($routes); $restObj = new FHIRCapabilityStatementRest($restJSON); + $restObj->setSecurity($this->getRestSecurity()); $capabilityStatement->addRest($restObj); $composerStr = file_get_contents($serverRoot . "/composer.json"); $composerObj = json_decode($composerStr, true); @@ -169,9 +171,69 @@ class FhirMetaDataRestController return $capabilityStatement; } + + /** + * Creates the Security Capability Statement and returns it. + * @return FHIRCapabilityStatementSecurity + */ + private function getRestSecurity() + { + $service = new FHIRCodeableConcept(); + $service->text = xlt("OAuth2 using SMART-on-FHIR profile (see http://docs.smarthealthit.org)"); + + $coding = new FHIRCoding(); + $coding->setSystem(new FHIRUrl("http://hl7.org/fhir/restful-security-service")); + $coding->setCode("SMART-on-FHIR"); + + $service->addCoding($coding) + ->setText(xlt("OAuth2 using SMART-on-FHIR profile (see http://docs.smarthealthit.org)")); + + $security = new FHIRCapabilityStatementSecurity(); + $security->addService($service); + $this->addOauthSecurityExtensions($security); + + return $security; + } + + /** + * Adds all of the FHIR REST Extensions needed for things such as SMART on FHIR + * @param FHIRCapabilityStatementSecurity $statement + */ + private function addOauthSecurityExtensions(FHIRCapabilityStatementSecurity $statement) + { + $authServer = new AuthorizationController(); + $oauthExtension = new FHIRExtension(); + $oauthExtension->setUrl(new FHIRUrl("http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris")); + $oauthUrls = [ + // @see http://www.hl7.org/fhir/smart-app-launch/StructureDefinition-oauth-uris.html + // and @see http://www.hl7.org/fhir/smart-app-launch/conformance/index.html#declaring-support-for-oauth2-endpoints + // token and authorize are required because we don't use implicit grant flow. + 'token' => $authServer->getTokenUrl() + ,'authorize' => $authServer->getAuthorizeUrl() + ,'register' => $authServer->getRegistrationUrl() + ,'introspect' => $authServer->getIntrospectionUrl() + // TODO: if we have these URIs we can provide them +// ,'manage' => $authServer->getManageUrl() +// ,'revoke' => '' + ]; + foreach ($oauthUrls as $url => $valueUri) { + $oauthEndpointExtension = new FHIRExtension(); + $oauthEndpointExtension->setUrl($url); + $oauthEndpointExtension->setValueUri($valueUri); + $oauthExtension->addExtension($oauthEndpointExtension); + } + $statement->addExtension($oauthExtension); + + // now add our SMART capabilities + foreach (Capability::SUPPORTED_CAPABILITIES as $smartCapability) { + $extension = new FHIRExtension(); + $extension->setUrl("http://fhir-registry.smarthealthit.org/StructureDefinition/capabilities"); + $extension->setValueCode($smartCapability); + $statement->addExtension($extension); + } + } + /** - * - * * Returns Metadata in CapabilityStatement FHIR resource format * */ diff --git a/src/RestControllers/SMART/SMARTConfigurationController.php b/src/RestControllers/SMART/SMARTConfigurationController.php new file mode 100644 index 000000000..07fce4978 --- /dev/null +++ b/src/RestControllers/SMART/SMARTConfigurationController.php @@ -0,0 +1,152 @@ + + * @copyright Copyright (c) 2020 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\RestControllers\SMART; + +use OpenEMR\FHIR\SMART\Capability; + +class SMARTConfigurationController +{ + /** + * @var \OpenEMR\RestControllers\AuthorizationController + */ + private $authServer; + + public function __construct(\OpenEMR\RestControllers\AuthorizationController $authServer) + { + $this->authServer = $authServer; + } + + /** + * Needed for OpenEMR\FHIR\SMART\Capability::CONTEXT_STYLE support + * TODO: adunsulag do we want to try and read from the scss files and generate some kind of styles... + * Reading the SMART FHIR spec author forums so few app writers are actually using this at all, it seems like we + * can just use defaults without getting into our skins... so that we can be spec compliant with ONC. + */ + public function getStyles() + { + $styles = [ + // copied from light theme background color + "color_background" => "#f8f9fa", + "color_error" => "#9e2d2d", + "color_highlight" => "#69b5ce", + "color_modal_backdrop" => "", + "color_success" => "#498e49", + // set text to black + "color_text" => "#000", + "dim_border_radius" => "6px", + "dim_font_size" => "13px", + "dim_spacing_size" => "20px", + // copied from our light theme font families + "font_family_body" => '"Lato","Helvetica",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"', + "font_family_heading" => '"Lato","Helvetica",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"' + ]; + return $styles; + } + + public function getConfig() + { + $authServer = $this->authServer; + // TODO: should we abstract the innards of the REST controller into its own class + // so we don't violate single responsibility principle? + $metadataController = new \OpenEMR\RestControllers\FHIR\FhirMetaDataRestController(); + $statement = $metadataController->getMetaData(); + + // TODO: adunsulag merge these with the OAUTH scopes + $scopesSupported = [ + "openid" + , "profile" +// , "launch" + , "launch/patient" + , "patient/*.*" +// , "user/*.*" +// , "offline_access" + ]; + // create hash dictionary + $scopes_dict = array_combine($scopesSupported, $scopesSupported); + $restAPIs = $statement->getRest(); + foreach ($restAPIs as $api) { + $resources = $api->getResource(); + foreach ($resources as $resource) { + // annoying that we switch into JSON instead of objects here + // violates the least surprise principle... + $interactions = $resource['interaction']; + $resourceType = $resource['type']; + + foreach ($interactions as $interaction) { + $scopeRead = $resourceType . ".read"; + $scopeWrite = $resourceType . ".write"; + switch ($interaction['code']) { + case 'read': + if (empty($scopes_dict[$scopeRead])) { + $scopes_dict[$scopeRead] = $scopeRead; + } + break; + case 'insert': // checkstyle doesn't like fallthrough statements apparently + if (empty($scopes_dict[$scopeWrite])) { + $scopes_dict[$scopeWrite] = $scopeWrite; + } + break; + case 'update': + if (empty($scopes_dict[$scopeWrite])) { + $scopes_dict[$scopeWrite] = $scopeWrite; + } + break; + } + } + } + } + $scopesSupported = array_keys($scopes_dict); + sort($scopesSupported); + + /** + * @see http://www.hl7.org/fhir/smart-app-launch/conformance/index.html#using-well-known + * authorization_endpoint: REQUIRED, URL to the OAuth2 authorization endpoint. + * token_endpoint: REQUIRED, URL to the OAuth2 token endpoint. + * token_endpoint_auth_methods: OPTIONAL, array of client authentication methods supported by the token endpoint. The options are “client_secret_post” and “client_secret_basic”. + * registration_endpoint: OPTIONAL, if available, URL to the OAuth2 dynamic registration endpoint for this FHIR server. + * scopes_supported: RECOMMENDED, array of scopes a client may request. See scopes and launch context. + * response_types_supported: RECOMMENDED, array of OAuth2 response_type values that are supported + * management_endpoint: RECOMMENDED, URL where an end-user can view which applications currently have access to data and can make adjustments to these access rights. + * introspection_endpoint : RECOMMENDED, URL to a server’s introspection endpoint that can be used to validate a token. + * revocation_endpoint : RECOMMENDED, URL to a server’s revoke endpoint that can be used to revoke a token. + * capabilities: REQUIRED, array of strings representing SMART capabilities (e.g., single-sign-on or launch-standalone) that the server supports. + */ + + $config = [ + "authorization_endpoint" => $authServer->getAuthorizeUrl(), + "token_endpoint" => $authServer->getTokenUrl(), + "registration_endpoint" => $authServer->getRegistrationUrl(), + "scopes_supported" => [ + $scopesSupported + ], + "response_types_supported" => [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token" + ], + // we don't support a management endpoint right now + // "management_endpoint" => "https://ehr.example.com/user/manage", + "introspection_endpoint" => $authServer->getIntrospectionUrl(), + // we don't revoke tokens right now + // "revocation_endpoint" => "https://ehr.example.com/user/revoke", + "capabilities" => Capability::SUPPORTED_CAPABILITIES + ]; + return $config; + } +} diff --git a/src/Services/PatientService.php b/src/Services/PatientService.php index 51297ad58..393f16f8c 100644 --- a/src/Services/PatientService.php +++ b/src/Services/PatientService.php @@ -22,6 +22,8 @@ use OpenEMR\Validators\ProcessingResult; class PatientService extends BaseService { + const TABLE_NAME = 'patient_data'; + /** * In the case where a patient doesn't have a picture uploaded, * this value will be returned so that the document controller @@ -36,7 +38,7 @@ class PatientService extends BaseService */ public function __construct() { - parent::__construct('patient_data'); + parent::__construct(self::TABLE_NAME); $this->patientValidator = new PatientValidator(); } @@ -325,4 +327,15 @@ class PatientService extends BaseService return $result['id']; } + + /** + * Fetch UUID for the patient id + * + * @param string $id - ID of Patient + * @return false if nothing found otherwise return UUID + */ + public function getUuid($pid) + { + return self::getUuidById($pid, self::TABLE_NAME, 'pid'); + } } diff --git a/tests/Tests/Api/CapabilityFhirTest.php b/tests/Tests/Api/CapabilityFhirTest.php index 0eafb5630..450373e85 100644 --- a/tests/Tests/Api/CapabilityFhirTest.php +++ b/tests/Tests/Api/CapabilityFhirTest.php @@ -2,6 +2,9 @@ namespace OpenEMR\Tests\Api; +use OpenEMR\FHIR\SMART\Capability; +use OpenEMR\RestControllers\AuthorizationController; +use OpenEMR\RestControllers\FHIR\FhirMetaDataRestController; use PHPUnit\Framework\TestCase; use OpenEMR\Tests\Api\ApiTestClient; @@ -18,14 +21,31 @@ use OpenEMR\Tests\Api\ApiTestClient; class CapabilityFhirTest extends TestCase { const CAPABILITY_FHIR_ENDPOINT = "/apis/default/fhir/metadata"; + const CAPABILITY_OAUTH_PREFIX = "/oauth2/default"; const CAPABILITY_FHIR_ENDPOINT_INVALID_SITE = "/apis/baddefault/fhir/metadata"; + /** + * @var ApiTestClient + */ private $testClient; + /** + * @var string + */ + private $baseUrl; + + /** + * Base url endpoint for oauth2 capability uris + * @var string + */ + private $oauthBaseUrl; + protected function setUp(): void { $baseUrl = getenv("OPENEMR_BASE_URL_API", true) ?: "https://localhost"; $this->testClient = new ApiTestClient($baseUrl, false); + $this->baseUrl = $baseUrl; + $this->oauthBaseUrl = $baseUrl . self::CAPABILITY_OAUTH_PREFIX; } public function tearDown(): void @@ -59,5 +79,82 @@ class CapabilityFhirTest extends TestCase { $actualResponse = $this->testClient->get(self::CAPABILITY_FHIR_ENDPOINT); $this->assertEquals(200, $actualResponse->getStatusCode()); + $body = $actualResponse->getBody(); + $this->assertNotNull($body); // make sure we have a body here + + + $statement = json_decode($body, true); + $this->assertCapabilityHasSMARTRequirements($statement); + } + + private function assertCapabilityHasSMARTRequirements($statement) + { + + $this->assertArrayHasKey('rest', $statement, "Rest capability must be defined"); + $restDef = $statement['rest'][0]; + $this->assertArrayHasKey('security', $restDef, "Rest security object defined"); + $this->assertArrayHasKey('service', $restDef['security'], "Rest security.service object defined and is not empty"); + $securityService = $restDef['security']['service'][0]; + + $this->assertArrayHasKey('coding', $securityService, "Rest security.service[].coding object defined"); + $this->assertArrayHasKey('text', $securityService, "Rest security.service[].text object defined"); + $coding = $securityService['coding'][0]; + $this->assertEquals("http://hl7.org/fhir/restful-security-service", $coding['system'], "Rest security.service[].coding[].system set"); + $this->assertEquals("SMART-on-FHIR", $coding['code'], "Rest security.service[].coding[].code set"); + + $this->assertArrayHasKey('extension', $restDef['security'], "Rest security.extension object defined"); + + $oauthExtension = $this->getExtension($restDef['security'], "http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris"); + $this->assertNotNull($oauthExtension, "Oauth extension should be defined in capability statement"); + $this->assertArrayHasKey('extension', $oauthExtension, "Oauth extension should have embedded extensions"); + $this->assertEquals(4, count($oauthExtension['extension']), "Extension should have token, authorize, manage extensions"); + + $tokenUri = $this->oauthBaseUrl . AuthorizationController::getTokenPath(); + $authorizeUri = $this->oauthBaseUrl . AuthorizationController::getAuthorizePath(); + $manageUri = $this->oauthBaseUrl . AuthorizationController::getManagePath(); + $this->assertEquals("token", $oauthExtension['extension'][0]['url'], "OAUTH Extension[0].url should be token"); +// $this->assertEquals($tokenUri, $oauthExtension['extension'][0]['valueUri'], "OAUTH Extension[0].valueUri does not match server token uri"); + + $this->assertEquals("authorize", $oauthExtension['extension'][1]['url'], "OAUTH Extension[1].url should be authorize"); +// $this->assertEquals($authorizeUri, $oauthExtension['extension'][1]['valueUri'], "OAUTH Extension[1].valueUri does not match server url"); + + // found out manage is optional, if we add it back in we can uncomment this. +// $this->assertEquals("manage", $oauthExtension['extension'][2]['url'], "OAUTH Extension[2].url should be authorize"); +// $this->assertEquals($manageUri, $oauthExtension['extension'][2]['valueUri'], "OAUTH Extension[2].valueUri does not match server url"); + + + $smartExtensions = $this->getExtensionList($restDef['security'], "http://fhir-registry.smarthealthit.org/StructureDefinition/capabilities"); + $enabledCapabilities = []; + foreach ($smartExtensions as $index => $extension) { + $enabledCapabilities[] = $extension['valueCode']; + } + + // the capabilities the server currently has + $expectedCapabilities = Capability::SUPPORTED_CAPABILITIES; + $missing_capabilities = array_diff($expectedCapabilities, $enabledCapabilities); + $this->assertEquals([], $missing_capabilities, "Capabilities statement is missing expected SMART extensions of " . implode(",", $missing_capabilities)); + } + + public function getExtension($capabilityStatementRestDefinition, $extensionUri) + { + $result = null; + $list = $this->getExtensionList($capabilityStatementRestDefinition, $extensionUri); + if (!empty($list)) { + $result = array_pop($list); + } + return $result; + } + + public function getExtensionList($capabilityStatementRestDefinition, $extensionUri) + { + $list = []; + if (!empty($capabilityStatementRestDefinition['extension'])) { + foreach ($capabilityStatementRestDefinition['extension'] as $index => $extension) { + if ($extension['url'] == $extensionUri) { + $list[] = $extension; + } + } + } + return $list; } } diff --git a/tests/Tests/Api/CapabilityFhirTest.php b/tests/Tests/Api/SmartConfigurationTest.php similarity index 58% copy from tests/Tests/Api/CapabilityFhirTest.php copy to tests/Tests/Api/SmartConfigurationTest.php index 0eafb5630..809110a7c 100644 --- a/tests/Tests/Api/CapabilityFhirTest.php +++ b/tests/Tests/Api/SmartConfigurationTest.php @@ -2,6 +2,8 @@ namespace OpenEMR\Tests\Api; +use OpenEMR\RestControllers\AuthorizationController; +use OpenEMR\RestControllers\FHIR\FhirMetaDataRestController; use PHPUnit\Framework\TestCase; use OpenEMR\Tests\Api\ApiTestClient; @@ -10,18 +12,30 @@ use OpenEMR\Tests\Api\ApiTestClient; * @coversDefaultClass OpenEMR\Tests\Api\ApiTestClient * @package OpenEMR * @link http://www.open-emr.org - * @author Brady Miller - * @copyright Copyright (c) 2018-2019 Brady Miller + * @author Stephen Nielson * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 * */ -class CapabilityFhirTest extends TestCase +class SmartConfigurationTest extends TestCase { - const CAPABILITY_FHIR_ENDPOINT = "/apis/default/fhir/metadata"; - const CAPABILITY_FHIR_ENDPOINT_INVALID_SITE = "/apis/baddefault/fhir/metadata"; + const SMART_CONFIG_ENDPOINT = "/apis/default/fhir/.well-known/smart-configuration"; + /** + * @var ApiTestClient + */ private $testClient; + /** + * @var string + */ + private $baseUrl; + + /** + * Base url endpoint for oauth2 capability uris + * @var string + */ + private $oauthBaseUrl; + protected function setUp(): void { $baseUrl = getenv("OPENEMR_BASE_URL_API", true) ?: "https://localhost"; @@ -39,25 +53,16 @@ class CapabilityFhirTest extends TestCase */ public function testInvalidPathGet() { - $actualResponse = $this->testClient->get(self::CAPABILITY_FHIR_ENDPOINT . "ss"); + $actualResponse = $this->testClient->get(self::SMART_CONFIG_ENDPOINT . "ss"); $this->assertEquals(401, $actualResponse->getStatusCode()); } /** - * @covers ::get with an invalid site - */ - public function testInvalidSiteGet() - { - $actualResponse = $this->testClient->get(self::CAPABILITY_FHIR_ENDPOINT_INVALID_SITE); - $this->assertEquals(400, $actualResponse->getStatusCode()); - } - - /** * @covers ::get */ public function testGet() { - $actualResponse = $this->testClient->get(self::CAPABILITY_FHIR_ENDPOINT); + $actualResponse = $this->testClient->get(self::SMART_CONFIG_ENDPOINT); $this->assertEquals(200, $actualResponse->getStatusCode()); } } diff --git a/tests/Tests/Unit/Common/Auth/OpenIDConnect/Entities/ClientEntityTest.php b/tests/Tests/Unit/Common/Auth/OpenIDConnect/Entities/ClientEntityTest.php new file mode 100644 index 000000000..7e1a6cdcf --- /dev/null +++ b/tests/Tests/Unit/Common/Auth/OpenIDConnect/Entities/ClientEntityTest.php @@ -0,0 +1,32 @@ + + * @copyright Copyright (c) 2020 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Tests\Unit\Common\Auth\OpenIDConnect\Entities; + +use OpenEMR\Common\Auth\OpenIDConnect\Entities\ClientEntity; +use PHPUnit\Framework\TestCase; + +class ClientEntityTest extends TestCase +{ + /** + * Checks to make sure the hasScope method is working properly + */ + public function testHasScope() + { + $client = new ClientEntity(); + $client->setScopes('openid email phone address launch api:oemr api:fhir api:port api:pofh'); + + $this->assertFalse($client->hasScope("bacon"), "invalid scope should not return true"); + $this->assertTrue($client->hasScope("launch"), "launch scope should have been found"); + $this->assertFalse($client->hasScope("launch/patient", "scope should not match against a prefix")); + } +} diff --git a/tests/Tests/Unit/FHIR/SMART/SMARTLaunchTokenTest.php b/tests/Tests/Unit/FHIR/SMART/SMARTLaunchTokenTest.php new file mode 100644 index 000000000..6beccb789 --- /dev/null +++ b/tests/Tests/Unit/FHIR/SMART/SMARTLaunchTokenTest.php @@ -0,0 +1,62 @@ + + * @copyright Copyright (c) 2020 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Tests\Unit\FHIR\SMART; + +use OpenEMR\FHIR\SMART\SMARTLaunchToken; +use PHPUnit\Framework\TestCase; + +class SMARTLaunchTokenTest extends TestCase +{ + public function testConstructor() + { + $patientUUID = "555-555-5555"; + $encounterID = "777-777-7777"; + $token = new SMARTLaunchToken($patientUUID, $encounterID); + + $this->assertEquals($patientUUID, $token->getPatient(), "Patient id should have been set in constructor"); + $this->assertEquals($encounterID, $token->getEncounter(), "Encounter id should have been set in constructor"); + $this->assertEquals(null, $token->getIntent(), "Other parameters should not be initialized in constructor"); + + $token = new SMARTLaunchToken(); + $this->assertEquals(null, $token->getPatient(), "Patient id on empty constructor should be null"); + $this->assertEquals(null, $token->getEncounter(), "Encounter id on empty constructor should be null"); + + $token = new SMARTLaunchToken($patientUUID); + $this->assertEquals($patientUUID, $token->getPatient(), "Patient id should be set"); + $this->assertEquals(null, $token->getEncounter(), "Encounter id on empty initialization should be null"); + + $token = new SMARTLaunchToken(null, $encounterID); + $this->assertEquals(null, $token->getPatient(), "Patient id on empty initialization should be set"); + $this->assertEquals($encounterID, $token->getEncounter(), "Encounter id should be set"); + } + /** + * Checks to make sure the hasScope method is working properly + */ + public function testDeserializeToken() + { + $patientUUID = "555-555-5555"; + $encounterID = "777-777-7777"; + $intent = SMARTLaunchToken::INTENT_PATIENT_DEMOGRAPHICS_DIALOG; + $token = new SMARTLaunchToken($patientUUID, $encounterID); + $token->setIntent($intent); + $serialized = $token->serialize(); + + $this->assertNotEmpty($serialized, "Token serialization should be a valid value"); + $this->assertTrue(is_string($serialized), "Token serialization should be set to a string"); + + $deserializedToken = SMARTLaunchToken::deserializeToken($serialized); + $this->assertInstanceOf(SMARTLaunchToken::class, $deserializedToken, "deserializedToken should return a valid token object"); + $this->assertEquals($patientUUID, $deserializedToken->getPatient(), "Patient UUID should be set from deserialization"); + $this->assertEquals($encounterID, $deserializedToken->getEncounter(), "Encounter UUID should be set from deserialization"); + $this->assertEquals($intent, $deserializedToken->getIntent(), "SMART Intent context should be set from deserialization"); + } +} -- 2.11.4.GIT