Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: #6525 Use EHR authorization for SMART Apps #6628

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion interface/patient_file/summary/demographics.php
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ function image_widget($doc_id, $doc_catg)

<head>
<?php
Header::setupHeader(['common']);
Header::setupHeader(['common','utility']);
require_once("$srcdir/options.js.php");
?>
<script>
Expand Down
8 changes: 7 additions & 1 deletion interface/smart/ehr-launch-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@

require_once("../globals.php");

$controller = new \OpenEMR\FHIR\SMART\SmartLaunchController();
use OpenEMR\Common\Acl\AccessDeniedException;
use OpenEMR\Common\Csrf\CsrfInvalidException;
use OpenEMR\Common\Csrf\CsrfUtils;
use OpenEMR\Common\Logging\SystemLogger;
use OpenEMR\FHIR\SMART\SmartLaunchController;

$controller = new SmartLaunchController();

$intentData = [];
try {
Expand Down
6 changes: 6 additions & 0 deletions library/globals.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -3233,6 +3233,12 @@ function gblTimeZones()
'0',
xl('Approval settings for 3rd party app/api access')
),
'oauth_ehr_launch_authorization_flow_skip' => array(
xl('OAuth2 EHR-Launch Authorization Flow Skip Enable App Setting'),
'bool',
'0',
xl('Enable an OAuth2 Client application to be configured to skip the login screen and the scope authorization screen if the user is already logged into the EHR.')
),

'cc_front_payments' => array(
xl('Accept Credit Card transactions from Front Payments'),
Expand Down
28 changes: 28 additions & 0 deletions library/js/utility.js
Original file line number Diff line number Diff line change
Expand Up @@ -475,3 +475,31 @@ if (typeof top.userDebug !== 'undefined' && (top.userDebug === '1' || top.userDe
};
}

(function(window, oeSMART) {
oeSMART.initLaunch = function(webroot, csrfToken) {
// allows this to be lazy defined
let xl = window.top.xl || function(text) { return text; };
let smartLaunchers = document.querySelectorAll('.smart-launch-btn');
for (let launch of smartLaunchers) {
launch.addEventListener('click', function (evt) {
let node = evt.target;
let intent = node.dataset.intent;
let clientId = node.dataset.clientId;
if (!intent || !clientId) {
console.error("mising intent parameter or client-id parameter");
return;
}

let url = webroot + '/interface/smart/ehr-launch-client.php?intent='
+ encodeURIComponent(intent) + '&client_id=' + encodeURIComponent(clientId)
+ "&csrf_token=" + encodeURIComponent(csrfToken);
let title = node.dataset.smartName || JSON.stringify(xl("Smart App"));
// we allow external dialog's here because that is what a SMART app is
let height = window.top.innerHeight; // do our full height here
dlgopen(url, '_blank', 'modal-full', height, '', title, {allowExternal: true});
});
}
};
window.oeSMART = oeSMART;
})(window, window.top.oeSMART || {});

16 changes: 16 additions & 0 deletions oauth2/smart/ehr-launch-autosubmit.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<html>
<body>
<form method="POST" action="{{ endpoint }}">
{# Do we want to show any kind of message...? For satellite or slow latency connections... we'd want to show something here. #}
</form>
<script>
// grab the launch token and autosubmit it to the launch endpoint
(function() {
document.addEventListener("DOMContentLoaded", function(event) {
var form = document.getElementsByTagName('form')[0];
form.submit();
});
})();
</script>
</body>
</html>
3 changes: 3 additions & 0 deletions sql/7_0_1-to-7_0_2_upgrade.sql
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ CREATE TABLE recent_patients (
patients TEXT,
PRIMARY KEY (user_id)
) ENGINE=InnoDB;

#IfMissingColumn oauth_clients skip_ehr_launch_authorization_flow
ALTER TABLE `oauth_clients` ADD COLUMN `skip_ehr_launch_authorization_flow` tinyint(1) NOT NULL DEFAULT '0';
Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs to also be in database.sql

Copy link
Sponsor Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what was causing the api test failures. Wasn't revealed by doing tests against the demo population (openemr-cmd drid) but it is revealed with an openemr-cmd dri which does not populate the database and uses the database.sql file.

Copy link
Sponsor Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

#EndIf

#IfMissingColumn document_template_profiles notify_trigger
Expand Down
1 change: 1 addition & 0 deletions sql/database.sql
Original file line number Diff line number Diff line change
Expand Up @@ -13075,6 +13075,7 @@ CREATE TABLE `oauth_clients` (
`policy_uri` text,
`tos_uri` text,
`is_enabled` tinyint(1) NOT NULL DEFAULT '0',
`skip_ehr_launch_authorization_flow` tinyint(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`client_id`)
) ENGINE=InnoDB;

Expand Down
20 changes: 20 additions & 0 deletions src/Common/Auth/OpenIDConnect/Entities/ClientEntity.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,15 @@ class ClientEntity implements ClientEntityInterface

protected $registrationDate;

/**
* @var bool true if the authorization flow (authentication and scope authorization) should be skipped for logged in ehr users
*/
private $skipEHRLaunchAuthorizationFlow;

public function __construct()
{
$this->scopes = [];
$this->skipEHRLaunchAuthorizationFlow = false;
}

public function setName($name): void
Expand Down Expand Up @@ -242,4 +248,18 @@ public function setLogoutRedirectUris(?string $logoutRedirectUris): void
{
$this->logoutRedirectUris = $logoutRedirectUris;
}

/**
* Whether the ehr launch should skip the authorization flow for a logged in user.
* @return bool
*/
public function shouldSkipEHRLaunchAuthorizationFlow(): bool
{
return $this->skipEHRLaunchAuthorizationFlow;
}

public function setSkipEHRLaunchAuthorizationFlow(bool $shouldSkip)
{
$this->skipEHRLaunchAuthorizationFlow = $shouldSkip;
}
}
22 changes: 20 additions & 2 deletions src/Common/Auth/OpenIDConnect/Repositories/ClientRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public function insertNewClient($clientId, $info, $site): bool
{
$user = $_SESSION['authUserID'] ?? null; // future use for provider client.
$is_confidential_client = empty($info['client_secret']) ? 0 : 1;
$skip_ehr_launch_authorization_flow = $info['skip_ehr_launch_authorization_flow'] == true ? 1 : 0;

$contacts = $info['contacts'];
$redirects = $info['redirect_uris'];
Expand Down Expand Up @@ -90,7 +91,7 @@ public function insertNewClient($clientId, $info, $site): bool
}

// TODO: @adunsulag why do we skip over request_uris when we have it in the outer function?
$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`, `is_enabled`) VALUES (?, ?, ?, ?, ?, ?, NOW(), NULL, ?, ?, 'authorization_code', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$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`, `is_enabled`, `skip_ehr_launch_authorization_flow`) VALUES (?, ?, ?, ?, ?, ?, NOW(), NULL, ?, ?, 'authorization_code', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$i_vals = array(
$clientId,
$info['client_role'],
Expand All @@ -111,7 +112,8 @@ public function insertNewClient($clientId, $info, $site): bool
($info['endorsements'] ?? null),
($info['policy_uri'] ?? null),
($info['tos_uri'] ?? null),
$is_client_enabled
$is_client_enabled,
$skip_ehr_launch_authorization_flow
);

return sqlQueryNoLog($sql, $i_vals, true); // throw an exception if it fails
Expand Down Expand Up @@ -260,6 +262,7 @@ private function hydrateClientEntityFromArray($client_record): ClientEntity
$client->setLogoutRedirectUris($client_record['logout_redirect_uris']);
$client->setContacts($client_record['contacts']);
$client->setRegistrationDate($client_record['register_date']);
$client->setSkipEHRLaunchAuthorizationFlow($client_record['skip_ehr_launch_authorization_flow'] == "1");
return $client;
}

Expand All @@ -272,4 +275,19 @@ public function generateRegistrationClientUriPath()
{
return HttpUtils::base64url_encode(RandomGenUtils::produceRandomBytes(16));
}

public function saveSkipEHRLaunchFlow(ClientEntity $client, bool $skipFlow)
{
// TODO: adunsulag do we want to eventually just have a save() method.. it would be very handy but not sure
// we want any oauth2 values being overwritten.
$skipFlowValue = $skipFlow === true ? 1 : 0;
$clientId = $client->getIdentifier();
$params = [$skipFlowValue, $clientId];
$res = sqlStatement("UPDATE oauth_clients SET skip_ehr_launch_authorization_flow=? WHERE client_id = ?", $params);
if ($res === false) {
// TODO: adunsulag is there a better exception to throw here in OpenEMR than runtime?
throw new \RuntimeException("Failed to save oauth_clients skip_ehr_launch_authorization_flow flag. Check logs for sql error");
}
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public function __construct($sessionArray = array())
public function getEHRLaunchContext()
{
$launch = $this->sessionArray['launch'] ?? null;
$this->logger->debug("SMARTSessionTokenContextBuilder->getEHRLaunchContext() launch context is", ['launch' => $launch]);
if (empty($launch)) {
return [];
}
Expand Down Expand Up @@ -101,6 +102,7 @@ public function getContextForScopesWithExistingContext(array $context, array $sc
public function getContextForScopes($scopes): array
{
$context = [];
$this->logger->debug("SMARTSessionTokenContextBuilder->getContextForScopes()");
if ($this->isStandaloneLaunchPatientRequest($scopes)) {
$context = $this->getStandaloneLaunchContext();
} else if ($this->isEHRLaunchRequest($scopes)) {
Expand Down
21 changes: 19 additions & 2 deletions src/Common/Session/SessionUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@

class SessionUtil
{
private const CORE_SESSION_ID = "OpenEMR";
private const OAUTH_SESSION_ID = 'authserverOpenEMR';

private static $gc_maxlifetime = 14400;
private static $sid_bits_per_character = 6;
private static $sid_length = 48;
Expand All @@ -71,6 +74,13 @@ class SessionUtil
private static $use_cookie_httponly = true;
private static $use_cookie_secure = false;

public static function switchToCoreSession($web_root, $read_only = true): void
{
session_write_close();
session_id($_COOKIE[self::CORE_SESSION_ID] ?? '');
self::coreSessionStart($web_root, $read_only);
}

public static function coreSessionStart($web_root, $read_only = true): void
{
// Note there is no system logger here since that class does not
Expand All @@ -79,7 +89,7 @@ public static function coreSessionStart($web_root, $read_only = true): void
'read_and_close' => $read_only,
'cookie_samesite' => self::$use_cookie_samesite,
'cookie_secure' => self::$use_cookie_secure,
'name' => 'OpenEMR',
'name' => self::CORE_SESSION_ID,
'cookie_httponly' => false,
'cookie_path' => ((!empty($web_root)) ? $web_root . '/' : '/'),
'gc_maxlifetime' => self::$gc_maxlifetime,
Expand Down Expand Up @@ -187,12 +197,19 @@ public static function apiSessionCookieDestroy(): void
self::standardSessionCookieDestroy();
}

public static function switchToOAuthSession($web_root): void
{
session_write_close();
session_id($_COOKIE[self::OAUTH_SESSION_ID] ?? '');
self::oauthSessionStart($web_root);
}

public static function oauthSessionStart($web_root): void
{
session_start([
'cookie_samesite' => "None",
'cookie_secure' => true,
'name' => 'authserverOpenEMR',
'name' => self::OAUTH_SESSION_ID,
'cookie_httponly' => self::$use_cookie_httponly,
'cookie_path' => ((!empty($web_root)) ? $web_root . '/oauth2/' : '/oauth2/'),
'gc_maxlifetime' => self::$gc_maxlifetime,
Expand Down
74 changes: 72 additions & 2 deletions src/FHIR/SMART/ClientAdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ public function dispatch($action, $request)
return $this->revokeAccessToken($clientId, $request);
} else if ($subAction == self::REVOKE_REFRESH_TOKEN) {
return $this->revokeRefreshToken($clientId, $request);
} else if ($subAction == 'enable-authorization-flow-skip') {
return $this->enableAuthorizationFlowSkipAction($clientId, $request);
} else if ($subAction == 'disable-authorization-flow-skip') {
return $this->disableAuthorizationFlowSkipAction($clientId, $request);
} else {
return $this->notFoundAction($request);
}
Expand Down Expand Up @@ -369,8 +373,14 @@ private function renderEdit(ClientEntity $client, $request)
$listAction = $this->getActionUrl(['list']);
$disableClientLink = $this->getActionUrl(['edit', $client->getIdentifier(), 'disable']);
$enableClientLink = $this->getActionUrl(['edit', $client->getIdentifier(), 'enable']);

$disableSkipAuthorizationFlowLink = $this->getActionUrl(['edit', $client->getIdentifier(), 'disable-authorization-flow-skip']);
$enableSkipAuthorizationFlowLink = $this->getActionUrl(['edit', $client->getIdentifier(), 'enable-authorization-flow-skip']);
$isEnabled = $client->isEnabled();
$allowSkipAuthSetting = $GLOBALS['oauth_ehr_launch_authorization_flow_skip'] === '1';
$skipAuthorizationFlow = $client->shouldSkipEHRLaunchAuthorizationFlow();
if (!$allowSkipAuthSetting) {
$skipAuthorizationFlow = false; // globals overrides this setting
}
$scopes = $client->getScopes();

$formValues = [
Expand Down Expand Up @@ -405,6 +415,12 @@ private function renderEdit(ClientEntity $client, $request)
, 'checked' => $isEnabled
, 'value' => 1
],
'skipEHRLaunchAuthorizationFlow' => [
'type' => 'checkbox'
,'label' => xl('Skip EHR Launch Authorization Flow')
, 'checked' => $skipAuthorizationFlow
, 'value' => 1
],
'role' => [
'type' => 'text'
,'label' => xl("Role")
Expand Down Expand Up @@ -432,6 +448,10 @@ private function renderEdit(ClientEntity $client, $request)
],
];

if (!$allowSkipAuthSetting) {
unset($formValues['skipEHRLaunchAuthorizationFlow']);
}

?>
<a href="<?php echo attr($listAction); ?>" class="btn btn-sm btn-secondary" onclick="top.restoreSession()">&lt; <?php echo xlt("Back to Client List"); ?></a>

Expand All @@ -440,8 +460,15 @@ private function renderEdit(ClientEntity $client, $request)
<h2>
<?php echo xlt('Edit'); ?> <em><?php echo text($client->getName()); ?></em>
<div class="float-right">
<?php if ($allowSkipAuthSetting) : ?>
<?php if ($skipAuthorizationFlow) : ?>
<a href="<?php echo attr($disableSkipAuthorizationFlowLink); ?>" class="btn btn-sm btn-primary" onclick="top.restoreSession()"><?php echo xlt('Enable EHR Launch Authorization Flow'); ?></a>
<?php else : ?>
<a href="<?php echo attr($enableSkipAuthorizationFlowLink); ?>" class="btn btn-sm btn-danger" onclick="top.restoreSession()"><?php echo xlt('Disable EHR Launch Authorization Flow'); ?></a>
<?php endif; ?>
<?php endif; ?>
<?php if ($isEnabled) : ?>
<a href="<?php echo attr($disableClientLink); ?>" class="btn btn-sm btn-primary" onclick="top.restoreSession()"><?php echo xlt('Disable Client'); ?></a>
<a href="<?php echo attr($disableClientLink); ?>" class="btn btn-sm btn-danger" onclick="top.restoreSession()"><?php echo xlt('Disable Client'); ?></a>
<?php else : ?>
<a href="<?php echo attr($enableClientLink); ?>" class="btn btn-sm btn-primary" onclick="top.restoreSession()"><?php echo xlt('Enable Client'); ?></a>
<?php endif; ?>
Expand Down Expand Up @@ -1111,4 +1138,47 @@ private function renderTokenToolsFooter()
<?php
$this->renderFooter();
}
private function enableAuthorizationFlowSkipAction(string $clientId, array $request)
{
$client = $this->clientRepo->getClientEntity($clientId);
if ($client === false) {
$this->notFoundAction($request);
return;
}
$message = xl('Enabled Authorization Flow Skip') . " " . $client->getName();
$this->handleAuthorizationFlowSkipAction($client, true, $message);
exit;
}
private function disableAuthorizationFlowSkipAction(string $clientId, array $request)
{
$client = $this->clientRepo->getClientEntity($clientId);
if ($client === false) {
$this->notFoundAction($request);
return;
}
$message = xl('Disabled Authorization Flow Skip') . " " . $client->getName();
$this->handleAuthorizationFlowSkipAction($client, false, $message);
exit;
}
private function handleAuthorizationFlowSkipAction(ClientEntity $client, bool $skipFlow, string $successMessage)
{
$client->setSkipEHRLaunchAuthorizationFlow($skipFlow);
try {
$this->clientRepo->saveSkipEHRLaunchFlow($client, $skipFlow);
$url = $this->getActionUrl(['edit', $client->getIdentifier()], ["queryParams" => ['message' => $successMessage]]);
header("Location: " . $url);
} catch (\Exception $ex) {
$this->logger->error(
"Failed to save client",
[
"exception" => $ex->getMessage(), "trace" => $ex->getTraceAsString()
, 'client' => $client->getIdentifier()
]
);

$message = xl('Client failed to save. Check system logs');
$url = $this->getActionUrl(['edit', $client->getIdentifier()], ["queryParams" => ['message' => $message]]);
header("Location: " . $url);
}
}
}