4 * SMARTAuthorizationController.php
6 * @link http://www.open-emr.org
7 * @author Stephen Nielson <stephen@nielson.org>
8 * @copyright Copyright (c) 2021 Stephen Nielson <stephen@nielson.org>
9 * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
12 namespace OpenEMR\RestControllers\SMART
;
14 use League\OAuth2\Server\Exception\OAuthServerException
;
15 use League\OAuth2\Server\RedirectUriValidators\RedirectUriValidator
;
16 use OpenEMR\Common\Http\Psr17Factory
;
17 use OpenEMR\Common\Acl\AccessDeniedException
;
18 use OpenEMR\Common\Auth\OpenIDConnect\Repositories\ClientRepository
;
19 use OpenEMR\Common\Csrf\CsrfUtils
;
20 use OpenEMR\Common\Session\SessionUtil
;
21 use OpenEMR\FHIR\SMART\SmartLaunchController
;
22 use OpenEMR\Services\PatientService
;
23 use Psr\Log\LoggerInterface
;
26 class SMARTAuthorizationController
29 * @var LoggerInterface
34 * The base URL of the oauth2 url
37 private $authBaseFullURL;
40 * The oauth2 endpoint url to send to once smart authorization is complete.
43 private $smartFinalRedirectURL;
46 * The directory that the oauth template files can be included from
49 private $oauthTemplateDir;
51 const PATIENT_SELECT_PATH
= "/smart/patient-select";
53 const PATIENT_SELECT_CONFIRM_ENDPOINT
= "/smart/patient-select-confirm";
55 const PATIENT_SEARCH_ENDPOINT
= "/smart/patient-search";
58 * Maximum number of patients that can be displayed in a search result.
60 const PATIENT_SEARCH_MAX_RESULTS
= 100;
64 * SMARTAuthorizationController constructor.
65 * @param $authBaseFullURL
66 * @param $smartFinalRedirectURL The URL that should be redirected to once all SMART authorizations are complete.
68 public function __construct(LoggerInterface
$logger, $authBaseFullURL, $smartFinalRedirectURL, $oauthTemplateDir)
70 $this->logger
= $logger;
71 $this->authBaseFullURL
= $authBaseFullURL;
72 $this->smartFinalRedirectURL
= $smartFinalRedirectURL;
73 $this->oauthTemplateDir
= $oauthTemplateDir;
77 * Checks to make sure that the passed in end point points to a valid SMART oauth2 endpoint
78 * @param $end_point string the route url
79 * @return bool true if the route should be handled by this controller, false otherwise
81 public function isValidRoute($end_point)
83 if (false !== stripos($end_point, self
::PATIENT_SELECT_PATH
)) {
86 if (false !== stripos($end_point, self
::PATIENT_SELECT_CONFIRM_ENDPOINT
)) {
89 if (false !== stripos($end_point, self
::PATIENT_SEARCH_ENDPOINT
)) {
96 * Handles the route endpoint and terminates the process upon completion.
99 public function dispatchRoute($end_point)
102 // order here matters
103 if (false !== stripos($end_point, self
::PATIENT_SELECT_CONFIRM_ENDPOINT
)) {
104 // session is maintained
105 $this->patientSelectConfirm();
107 } else if (false !== stripos($end_point, self
::PATIENT_SELECT_PATH
)) {
108 // session is maintained
109 $this->patientSelect();
111 } else if (false !== stripos($end_point, self
::PATIENT_SEARCH_ENDPOINT
)) {
112 // session is maintained
113 $this->patientSearch();
116 $this->logger
->error("SMARTAuthorizationController->dispatchRoute() called with invalid route. verify isValidRoute configured properly", ['end_point' => $end_point]);
117 http_response_code(404);
122 * Does the current request and session data require an oauth2 flow to be interrupted and go through the smart
126 public function needSMARTAuthorization()
128 if (empty($_SESSION['puuid']) && strpos($_SESSION['scopes'], SmartLaunchController
::CLIENT_APP_STANDALONE_LAUNCH_SCOPE
) !== false) {
129 $this->logger
->debug("AuthorizationController->userLogin() SMART app request for patient context ", ['scopes' => $_SESSION['scopes'], 'puuid' => $_SESSION['puuid'] ??
null]);
136 * Returns the first SMART oauth2 authorization path to start the SMART flow.
139 public function getSmartAuthorizationPath()
141 // we can extend this to be a bunch of things based on any additional authorization contexts we need
142 // to support things like encounter selection, etc, but for now we only support patient selector launch
143 return self
::PATIENT_SELECT_PATH
;
147 * Receives the response of the patient selected, sets up the session and redirects back to the oauth2 regular flow
149 public function patientSelectConfirm()
151 $user_uuid = $_SESSION['user_id'];
152 if (!isset($user_uuid)) {
153 $this->logger
->error("SMARTAuthorizationController->patientSelect() Unauthorized call, user has not authenticated");
154 http_response_code(401);
155 die(xlt('Invalid Request'));
158 if (!CsrfUtils
::verifyCsrfToken($_POST["csrf_token"], 'oauth2')) {
159 $this->logger
->error("SMARTAuthorizationController->patientSelect() Invalid CSRF token");
160 CsrfUtils
::csrfNotVerified(true, true, true);
164 // set our patient information up in our pid so we can handle our code property...
166 $patient_id = $_POST['patient_id']; // this patient_id is actually a uuid.. wierd
167 $searchController = new PatientContextSearchController(new PatientService(), $this->logger
);
168 // throws access denied if user doesn't have access
169 $foundPatient = $searchController->getPatientForUser($patient_id, $user_uuid);
170 // put PID in session
171 $_SESSION['puuid'] = $patient_id;
173 // now redirect to our scope-authorize
174 $redirect = $this->smartFinalRedirectURL
;
175 header("Location: $redirect");
176 } catch (AccessDeniedException
$error) {
177 // or should we present some kind of error display form...
178 $this->logger
->error("AuthorizationController->patientSelect() Exception thrown", ['exception' => $error->getMessage(), 'userId' => $user_uuid]);
179 // make sure to grab the redirect uri before the session is destroyed
180 $redirectUri = $this->getClientRedirectURI();
181 SessionUtil
::oauthSessionCookieDestroy();
182 $error = OAuthServerException
::accessDenied("No access to patient data for this user", $redirectUri, $error);
183 $response = (new Psr17Factory())->createResponse();
184 $this->emitResponse($error->generateHttpResponse($response));
185 } catch (\Exception
$error) {
186 // error occurred, no patients found just display the screen with an error message
187 $this->logger
->error("AuthorizationController->patientSelect() Exception thrown", ['exception' => $error->getMessage()]);
188 $errorMessage = "There was a server error in loading patients. Contact your system administrator for assistance";
189 $url = $this->authBaseFullURL
. self
::PATIENT_SELECT_PATH
. "?error=" . urlencode($errorMessage);
190 header("Location: " . $url);
196 * Displays the patient list and let's user's search and choose a patient. If the user doesn't have access to patient
197 * demographics we die on the security piece.
198 * @return false|string
200 public function patientSelect()
202 $user_uuid = $_SESSION['user_id'];
203 if (empty($user_uuid)) {
204 $this->logger
->error("SMARTAuthorizationController->patientSelect() Unauthorized call, user has not authenticated");
205 http_response_code(401);
206 die(xlt('Invalid Request'));
212 // handle our patient selected piece, populate the session and now present the authorize piece
213 $redirect = $this->authBaseFullURL
. self
::PATIENT_SELECT_CONFIRM_ENDPOINT
;
214 $searchAction = $this->authBaseFullURL
. self
::PATIENT_SELECT_PATH
;
215 $errorMessage = $_GET['error'] ??
'';
218 // we've got a user by their UUID... we need to grab the db user id
219 $searchParams = $_GET['search'] ??
[];
221 // grab our list of patients to select from.
222 $searchController = new PatientContextSearchController(new PatientService(), $this->logger
);
223 $patients = $searchController->searchPatients($searchParams, $user_uuid);
224 $hasMore = count($patients) > self
::PATIENT_SEARCH_MAX_RESULTS
;
225 $patients = $hasMore ?
array_slice($patients, 0, self
::PATIENT_SEARCH_MAX_RESULTS
) : $patients;
227 require_once($this->oauthTemplateDir
. "smart/patient-select.php");
228 } catch (AccessDeniedException
$error) {
229 // make sure to grab the redirect uri before the session is destroyed
230 $redirectUri = $this->getClientRedirectURI();
231 $this->logger
->error("AuthorizationController->patientSelect() Exception thrown", ['exception' => $error->getMessage(), 'userId' => $user_uuid]);
232 SessionUtil
::oauthSessionCookieDestroy();
233 $error = OAuthServerException
::accessDenied("No access to patient data for this user", $redirectUri, $error);
234 $response = (new Psr17Factory())->createResponse();
235 $this->emitResponse($error->generateHttpResponse($response));
236 } catch (\Exception
$error) {
237 // error occurred, no patients found just display the screen with an error message
238 $error_message = "There was a server error in loading patients. Contact your system administrator for assistance";
239 $this->logger
->error("AuthorizationController->patientSelect() Exception thrown", ['exception' => $error->getMessage()]);
240 require_once($this->oauthTemplateDir
. "smart/patient-select.php");
244 // TODO: adunsulag should this be moved into a trait so we can share the functionality with AuthorizationController?
245 public function emitResponse($response): void
247 if (headers_sent()) {
248 throw new RuntimeException('Headers already sent.');
250 $statusLine = sprintf(
252 $response->getProtocolVersion(),
253 $response->getStatusCode(),
254 $response->getReasonPhrase()
256 header($statusLine, true);
257 foreach ($response->getHeaders() as $name => $values) {
258 $responseHeader = sprintf('%s: %s', $name, $response->getHeaderLine($name));
259 header($responseHeader, false);
262 echo $response->getBody();
266 * Returns the client redirect URI to send error responses back.
267 * @return string|null
269 private function getClientRedirectURI()
271 $client_id = $_SESSION['client_id'];
272 $repo = new ClientRepository();
273 $client = $repo->getClientEntity($client_id);
274 $uriList = $client->getRedirectUri();
276 if (is_array($uriList) && !empty($uriList)) {
277 $validator = new RedirectUriValidator($uri);
278 $uri = $uriList[0]; // we grab the first one if we don't have one in the session already
280 // this is probably overly paranoid but we want to safeguard against any session tampering and use the same logic
281 // to validate the redirect_uri as we do elsewhere in the system
282 // if we have multiple redirect_uris and we have the redirect uri in our session
283 if (!empty($_SESSION['redirect_uri'])) {
284 if ($validator->validateRedirectUri($_SESSION['redirect_uri'])) {
285 $uri = $_SESSION['redirect_uri'];