Skip to content

Commit

Permalink
Openemr fix 6574 6575 6576 fhir updates (openemr#6577)
Browse files Browse the repository at this point in the history
* Fixes openemr#6575 refactor oauth2 client save

Made it possible to save an oauth2 client using the client repository.
This makes it so OpenEMR callers don't need to include the RestConfig
class or deal with the AuthorizationController to generate a smart app
client record if needed.  Module writers can register their own smart
app as needed using this functionality.

* Fixes openemr#6576 FHIR Capability statement createUpdate

made it so the create update flag is set to false so API consumers know
they can't provider their own logical ids when creating a resource.

Fixes openemr#6576

* Add systems for task/questionnaire

IN preparation for future Task and Questionnaire endpoints we want to
add the system constants here.

* Add mapped service helper methods.

Add helper methods for grabbing a token using a subset of codes as well
as grabbing a service based on a specific code.

* Fix url bug in utils service to prevent slashes

* Add helper method to Token search for isUuid

* Fixes openemr#6574 QuestionnaireResponse search

The questionnaire response search would fail when searching on things
such as id due to duplicate column names.  In order to make this service
work in a FHIR context we need to fix up the search method and handle
the uuid translations.

Fixes openemr#6574
  • Loading branch information
adunsulag authored Jun 17, 2023
1 parent f625487 commit 595d078
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 91 deletions.
107 changes: 107 additions & 0 deletions src/Common/Auth/OpenIDConnect/Repositories/ClientRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use OpenEMR\Common\Auth\OpenIDConnect\Entities\ClientEntity;
use OpenEMR\Common\Crypto\CryptoGen;
use OpenEMR\Common\Logging\SystemLogger;
use OpenEMR\Common\Utils\HttpUtils;
use OpenEMR\Common\Utils\RandomGenUtils;
use Psr\Log\LoggerInterface;

class ClientRepository implements ClientRepositoryInterface
Expand All @@ -25,9 +27,104 @@ class ClientRepository implements ClientRepositoryInterface
*/
private $logger;

private $cryptoGen;

public function __construct()
{
$this->logger = new SystemLogger();
$this->cryptoGen = new CryptoGen();
}

/**
* @return CryptoGen
*/
public function getCryptoGen(): CryptoGen
{
return $this->cryptoGen;
}

/**
* @param CryptoGen $cryptoGen
* @return ClientRepository
*/
public function setCryptoGen(CryptoGen $cryptoGen): ClientRepository
{
$this->cryptoGen = $cryptoGen;
return $this;
}

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;

$contacts = $info['contacts'];
$redirects = $info['redirect_uris'];
if (is_array($redirects)) {
// need to combine our redirects if we are an array... this is due to the legacy implementation of this data
$redirects = implode("|", $redirects);
}
$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';

$scopes = explode(" ", $info['scope']);
$scopeRepo = new ScopeRepository();

if ($scopeRepo->hasScopesThatRequireManualApproval($is_confidential_client == 1, $scopes)) {
$is_client_enabled = 0; // disabled
} else {
$is_client_enabled = 1; // enabled
}

// encrypt the client secret
if (!empty($info['client_secret'])) {
$cryptoGen = $this->getCryptoGen();
$info['client_secret'] = $cryptoGen->encryptStandard($info['client_secret']);
}

// 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', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$i_vals = array(
$clientId,
$info['client_role'],
$info['client_name'],
$info['client_secret'],
$info['registration_access_token'],
$info['registration_client_uri_path'],
$contacts,
$redirects,
$info['scope'],
$user,
$site,
$is_confidential_client,
$logout_redirect_uris,
($info['jwks_uri'] ?? null),
($info['jwks'] ?? null),
($info['initiate_login_uri'] ?? null),
($info['endorsements'] ?? null),
($info['policy_uri'] ?? null),
($info['tos_uri'] ?? null),
$is_client_enabled
);

return sqlQueryNoLog($sql, $i_vals, true); // throw an exception if it fails
}

public function generateClientId()
{
return HttpUtils::base64url_encode(RandomGenUtils::produceRandomBytes(32));
}

public function generateClientSecret()
{
return HttpUtils::base64url_encode(RandomGenUtils::produceRandomBytes(64));
}

/**
Expand Down Expand Up @@ -165,4 +262,14 @@ private function hydrateClientEntityFromArray($client_record): ClientEntity
$client->setRegistrationDate($client_record['register_date']);
return $client;
}

public function generateRegistrationAccessToken()
{
return HttpUtils::base64url_encode(RandomGenUtils::produceRandomBytes(32));
}

public function generateRegistrationClientUriPath()
{
return HttpUtils::base64url_encode(RandomGenUtils::produceRandomBytes(16));
}
}
20 changes: 20 additions & 0 deletions src/Common/Utils/HttpUtils.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/**
* HttpUtils utility class for functions dealing with urls and http.
* @package openemr
* @link http://www.open-emr.org
* @author Discover and Change, Inc. <[email protected]>
* @copyright Copyright (c) 2023 Discover and Change, Inc. <[email protected]>
* @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
*/

namespace OpenEMR\Common\Utils;

class HttpUtils
{
public static function base64url_encode($data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}
84 changes: 11 additions & 73 deletions src/RestControllers/AuthorizationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
use OpenEMR\Common\Http\Psr17Factory;
use OpenEMR\Common\Logging\SystemLogger;
use OpenEMR\Common\Session\SessionUtil;
use OpenEMR\Common\Utils\HttpUtils;
use OpenEMR\Common\Utils\RandomGenUtils;
use OpenEMR\Common\Uuid\UuidRegistry;
use OpenEMR\FHIR\Config\ServerConfig;
Expand Down Expand Up @@ -214,9 +215,10 @@ public function clientRegistration(): void
// @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));
$reg_client_uri_path = $this->base64url_encode(RandomGenUtils::produceRandomBytes(16));
$clientRepository = new ClientRepository();
$client_id = $clientRepository->generateClientId();
$reg_token = $clientRepository->generateRegistrationAccessToken();
$reg_client_uri_path = $clientRepository->generateRegistrationClientUriPath();
$params = array(
'client_id' => $client_id,
'client_id_issued_at' => time(),
Expand All @@ -228,7 +230,7 @@ public function clientRegistration(): void
// only include secret if a confidential app else force PKCE for native and web apps.
$client_secret = '';
if ($data['application_type'] === 'private') {
$client_secret = $this->base64url_encode(RandomGenUtils::produceRandomBytes(64));
$client_secret = $clientRepository->generateClientSecret();
$params['client_secret'] = $client_secret;
$params['client_role'] = 'user';

Expand Down Expand Up @@ -271,9 +273,10 @@ public function clientRegistration(): void
throw new OAuthServerException('post_logout_redirect_uris is invalid', 0, 'invalid_client_metadata');
}
// save to oauth client table
$badSave = $this->newClientSave($client_id, $params);
if (!empty($badSave)) {
throw OAuthServerException::serverError("Try again. Unable to create account");
try {
$clientRepository->insertNewClient($client_id, $params, $this->siteId);
} catch (\Exception $exception) {
throw OAuthServerException::serverError("Try again. Unable to create account", $exception);
}
$reg_uri = $this->authBaseFullUrl . '/client/' . $reg_client_uri_path;
unset($params['registration_client_uri_path']);
Expand Down Expand Up @@ -352,7 +355,7 @@ private function createServerRequest(): ServerRequestInterface

public function base64url_encode($data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
return HttpUtils::base64url_encode($data);
}

public function base64url_decode($token)
Expand All @@ -361,71 +364,6 @@ public function base64url_decode($token)
return base64_decode($b64);
}

public function newClientSave($clientId, $info): bool
{
$user = $_SESSION['authUserID'] ?? null; // future use for provider client.
$site = $this->siteId;
$is_confidential_client = empty($info['client_secret']) ? 0 : 1;

$contacts = $info['contacts'];
$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';

$scopes = explode(" ", $info['scope']);
$scopeRepo = new ScopeRepository();

if ($scopeRepo->hasScopesThatRequireManualApproval($is_confidential_client == 1, $scopes)) {
$is_client_enabled = 0; // disabled
} else {
$is_client_enabled = 1; // enabled
}

// encrypt the client secret
if (!empty($info['client_secret'])) {
$info['client_secret'] = $this->cryptoGen->encryptStandard($info['client_secret']);
}


try {
// 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', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$i_vals = array(
$clientId,
$info['client_role'],
$info['client_name'],
$info['client_secret'],
$info['registration_access_token'],
$info['registration_client_uri_path'],
$contacts,
$redirects,
$info['scope'],
$user,
$site,
$is_confidential_client,
$logout_redirect_uris,
($info['jwks_uri'] ?? null),
($info['jwks'] ?? null),
($info['initiate_login_uri'] ?? null),
($info['endorsements'] ?? null),
($info['policy_uri'] ?? null),
($info['tos_uri'] ?? null),
$is_client_enabled
);

return sqlQueryNoLog($sql, $i_vals);
} catch (\RuntimeException $e) {
die($e);
}
}

public function emitResponse($response): void
{
if (headers_sent()) {
Expand Down
3 changes: 3 additions & 0 deletions src/RestControllers/RestControllerHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,9 @@ public function getCapabilityRESTObject($routes, $serviceClassNameSpace = self::

if (empty($capResource)) {
$capResource = new FHIRCapabilityStatementResource();
// make it explicit that we do not let the user use their own resource ids to create a new resource
// in the PUT/update operation.
$capResource->setUpdateCreate(false);
$capResource->setType(new FHIRCode($type));
$capResource->setProfile(new FHIRCanonical($structureDefinition . $type));

Expand Down
8 changes: 8 additions & 0 deletions src/Services/FHIR/FhirCodeSystemConstants.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,12 @@ class FhirCodeSystemConstants
* available here: https://vsac.nlm.nih.gov/valueset/2.16.840.1.113762.1.4.1099.27/expansion/Latest
*/
const CARE_TEAM_MEMBER_FUNCTION_SNOMEDCT = "2.16.840.1.113762.1.4.1099.27";

/**
* Required for Structured Data Collection (SDC) Task implementations
* @see https://build.fhir.org/ig/HL7/sdc/ValueSet-task-code.html
*/
const HL7_SDC_TASK_TEMP = "https://build.fhir.org/ig/HL7/sdc/CodeSystem-temp.html";

const HL7_SDC_TASK_SERVICE_REQUEST = "http://hl7.org/fhir/CodeSystem/task-code";
}
25 changes: 25 additions & 0 deletions src/Services/FHIR/Traits/MappedServiceCodeTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ trait MappedServiceCodeTrait
{
use MappedServiceTrait;

public function getServiceListForCode(TokenSearchField $field)
{
// TODO: @adunsulag if we want to aggregate multiple code parameters we will need to handle selecting a subset of codes
// per service
$serviceList = [];
foreach ($this->getMappedServices() as $service) {
$subsetCodes = $this->getTokenSearchFieldWithSupportedCodes($service, $field);
if (!empty($subsetCodes->getValues())) {
$serviceList[] = $service;
}
}
return $serviceList;
}
public function getServiceForCode(TokenSearchField $field, $defaultCode)
{
// shouldn't ever hit the default but we have it there just in case.
Expand All @@ -35,6 +48,18 @@ public function getServiceForCode(TokenSearchField $field, $defaultCode)
throw new SearchFieldException($field->getField(), "Invalid or unsupported code");
}

public function getTokenSearchFieldWithSupportedCodes(FhirServiceBase $service, TokenSearchField $field)
{
$subsetCodes = [];
foreach ($field->getValues() as $value) {
$searchCode = $value->getCode();
if ($service->supportsCode($searchCode)) {
$subsetCodes[] = $value;
}
}
return new TokenSearchField($field->getField(), $subsetCodes, $field->isUuid());
}

public function getServiceForCategory(TokenSearchField $category, $defaultCategory): FhirServiceBase
{
// let the field parse our category
Expand Down
15 changes: 15 additions & 0 deletions src/Services/FHIR/Traits/MappedServiceTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ public function searchAllServices($fhirSearchParams, $puuidBind)
return $processingResult;
}

public function searchServices(array $services, $fhirSearchParams, $puuidBind)
{
$processingResult = new ProcessingResult();
foreach ($services as $service) {
$innerResult = $service->getAll($fhirSearchParams, $puuidBind);
$processingResult->addProcessingResult($innerResult);
if ($processingResult->hasErrors()) {
// clear our data out and just return the errors
$processingResult->clearData();
return $processingResult;
}
}
return $processingResult;
}

public function searchAllServicesWithSupportedFields($fhirSearchParams, $puuidBind)
{
$processingResult = new ProcessingResult();
Expand Down
6 changes: 5 additions & 1 deletion src/Services/FHIR/UtilsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ public static function createCanonicalUrlForResource($resourceType, $uuid): FHIR
{

$siteConfig = new ServerConfig();
$url = $siteConfig->getFhirUrl() . $resourceType . '/' . $uuid;
$fhirUrl = $siteConfig->getFhirUrl() ?? "";
if (strrpos("/", $fhirUrl) != strlen($fhirUrl)) {
$fhirUrl .= "/";
}
$url = $fhirUrl . $resourceType . '/' . $uuid;
$cannonical = new FHIRCanonical();
$cannonical->setValue($url);
return $cannonical;
Expand Down
Loading

0 comments on commit 595d078

Please sign in to comment.