Fixes #6444, #6419 oauth2 redirect, imports (#6445)
[openemr.git] / src / RestControllers / AuthorizationController.php
blobeb00468f3aa22cc53fd24a357c8574f2769a998e
1 <?php
3 /**
4 * Authorization Server Member
6 * @package OpenEMR
7 * @link http://www.open-emr.org
8 * @author Jerry Padgett <sjpadgett@gmail.com>
9 * @author Brady Miller <brady.g.miller@gmail.com>
10 * @copyright Copyright (c) 2020 Jerry Padgett <sjpadgett@gmail.com>
11 * @copyright Copyright (c) 2020 Brady Miller <brady.g.miller@gmail.com>
12 * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
15 namespace OpenEMR\RestControllers;
17 require_once(__DIR__ . "/../Common/Session/SessionUtil.php");
19 use DateInterval;
20 use DateTimeImmutable;
21 use Exception;
22 use Google\Service\CloudHealthcare\FhirConfig;
23 use GuzzleHttp\Client;
24 use Lcobucci\JWT\Parser;
25 use Lcobucci\JWT\Signer\Rsa\Sha256;
26 use Lcobucci\JWT\ValidationData;
27 use League\OAuth2\Server\AuthorizationServer;
28 use League\OAuth2\Server\CryptKey;
29 use League\OAuth2\Server\CryptTrait;
30 use League\OAuth2\Server\Entities\ScopeEntityInterface;
31 use League\OAuth2\Server\Exception\OAuthServerException;
32 use League\OAuth2\Server\Grant\AuthCodeGrant;
33 use League\OAuth2\Server\Grant\ClientCredentialsGrant;
34 use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
35 use Nyholm\Psr7Server\ServerRequestCreator;
36 use OpenEMR\Common\Auth\AuthUtils;
37 use OpenEMR\Common\Auth\MfaUtils;
38 use OpenEMR\Common\Auth\OAuth2KeyConfig;
39 use OpenEMR\Common\Auth\OAuth2KeyException;
40 use OpenEMR\Common\Auth\OpenIDConnect\Entities\ClientEntity;
41 use OpenEMR\Common\Auth\OpenIDConnect\Entities\ScopeEntity;
42 use OpenEMR\Common\Auth\OpenIDConnect\Entities\UserEntity;
43 use OpenEMR\Common\Auth\OpenIDConnect\Grant\CustomAuthCodeGrant;
44 use OpenEMR\Common\Auth\OpenIDConnect\Grant\CustomClientCredentialsGrant;
45 use OpenEMR\Common\Auth\OpenIDConnect\Grant\CustomPasswordGrant;
46 use OpenEMR\Common\Auth\OpenIDConnect\Grant\CustomRefreshTokenGrant;
47 use OpenEMR\Common\Auth\OpenIDConnect\IdTokenSMARTResponse;
48 use OpenEMR\Common\Auth\OpenIDConnect\JWT\JsonWebKeyParser;
49 use OpenEMR\Common\Auth\OpenIDConnect\Repositories\AccessTokenRepository;
50 use OpenEMR\Common\Auth\OpenIDConnect\Repositories\AuthCodeRepository;
51 use OpenEMR\Common\Auth\OpenIDConnect\Repositories\ClientRepository;
52 use OpenEMR\Common\Auth\OpenIDConnect\Repositories\IdentityRepository;
53 use OpenEMR\Common\Auth\OpenIDConnect\Repositories\RefreshTokenRepository;
54 use OpenEMR\Common\Auth\OpenIDConnect\Repositories\ScopeRepository;
55 use OpenEMR\Common\Auth\OpenIDConnect\Repositories\UserRepository;
56 use OpenEMR\Common\Auth\UuidUserAccount;
57 use OpenEMR\Common\Crypto\CryptoGen;
58 use OpenEMR\Common\Csrf\CsrfUtils;
59 use OpenEMR\Common\Http\Psr17Factory;
60 use OpenEMR\Common\Logging\SystemLogger;
61 use OpenEMR\Common\Session\SessionUtil;
62 use OpenEMR\Common\Utils\RandomGenUtils;
63 use OpenEMR\Common\Uuid\UuidRegistry;
64 use OpenEMR\FHIR\Config\ServerConfig;
65 use OpenEMR\FHIR\SMART\SmartLaunchController;
66 use OpenEMR\RestControllers\SMART\SMARTAuthorizationController;
67 use OpenEMR\Services\TrustedUserService;
68 use OpenIDConnectServer\ClaimExtractor;
69 use OpenIDConnectServer\Entities\ClaimSetEntity;
70 use Psr\Http\Message\ResponseInterface;
71 use Psr\Http\Message\ServerRequestInterface;
72 use Psr\Log\LoggerInterface;
73 use RuntimeException;
75 class AuthorizationController
77 use CryptTrait;
79 public const ENDPOINT_SCOPE_AUTHORIZE_CONFIRM = "/scope-authorize-confirm";
81 public const GRANT_TYPE_PASSWORD = 'password';
82 public const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials';
83 public const OFFLINE_ACCESS_SCOPE = 'offline_access';
85 public $authBaseUrl;
86 public $authBaseFullUrl;
87 public $siteId;
88 private $privateKey;
89 private $passphrase;
90 private $publicKey;
91 private $oaEncryptionKey;
92 private $grantType;
93 private $providerForm;
94 private $authRequestSerial;
95 private $cryptoGen;
96 private $userId;
98 /**
99 * @var SMARTAuthorizationController
101 private $smartAuthController;
104 * @var LoggerInterface
106 private $logger;
109 * Handles CRUD operations for OAUTH2 Trusted Users
110 * @var TrustedUserService
112 private $trustedUserService;
115 * @var \RestConfig
117 private $restConfig;
119 public function __construct($providerForm = true)
121 $gbl = \RestConfig::GetInstance();
122 $this->restConfig = $gbl;
123 $this->logger = new SystemLogger();
125 $this->siteId = $_SESSION['site_id'] ?? $gbl::$SITE;
126 $this->authBaseUrl = $GLOBALS['webroot'] . '/oauth2/' . $this->siteId;
127 $this->authBaseFullUrl = self::getAuthBaseFullURL();
128 // used for session stash
129 $this->authRequestSerial = $_SESSION['authRequestSerial'] ?? '';
130 // Create a crypto object that will be used for for encryption/decryption
131 $this->cryptoGen = new CryptoGen();
132 // verify and/or setup our key pairs.
133 $this->configKeyPairs();
135 // true will display client/user server sign in. false, not.
136 $this->providerForm = $providerForm;
138 $this->smartAuthController = new SMARTAuthorizationController(
139 $this->logger,
140 $this->authBaseFullUrl,
141 $this->authBaseFullUrl . self::ENDPOINT_SCOPE_AUTHORIZE_CONFIRM,
142 __DIR__ . "/../../oauth2/"
145 $this->trustedUserService = new TrustedUserService();
148 private function configKeyPairs(): void
150 $response = $this->createServerResponse();
151 try {
152 $oauth2KeyConfig = new OAuth2KeyConfig($GLOBALS['OE_SITE_DIR']);
153 $oauth2KeyConfig->configKeyPairs();
154 $this->privateKey = $oauth2KeyConfig->getPrivateKeyLocation();
155 $this->publicKey = $oauth2KeyConfig->getPublicKeyLocation();
156 $this->oaEncryptionKey = $oauth2KeyConfig->getEncryptionKey();
157 $this->passphrase = $oauth2KeyConfig->getPassPhrase();
158 } catch (OAuth2KeyException $exception) {
159 $this->logger->error("OpenEMR error - " . $exception->getMessage() . ", so forced exit");
160 $serverException = OAuthServerException::serverError(
161 "Security error - problem with authorization server keys.",
162 $exception
164 SessionUtil::oauthSessionCookieDestroy();
165 $this->emitResponse($serverException->generateHttpResponse($response));
166 exit;
170 public function clientRegistration(): void
172 $response = $this->createServerResponse();
173 $request = $this->createServerRequest();
174 $headers = array();
175 try {
176 $request_headers = $request->getHeaders();
177 foreach ($request_headers as $header => $value) {
178 $headers[strtolower($header)] = $value[0];
180 if (!$headers['content-type'] || strpos($headers['content-type'], 'application/json') !== 0) {
181 throw new OAuthServerException('Unexpected content type', 0, 'invalid_client_metadata');
183 $json = file_get_contents('php://input');
184 if (!$json) {
185 throw new OAuthServerException('No JSON body', 0, 'invalid_client_metadata');
187 $data = json_decode($json, true);
188 if (!$data) {
189 throw new OAuthServerException('Invalid JSON', 0, 'invalid_client_metadata');
191 // many of these are optional and are here if we want to implement
192 $keys = array('contacts' => null,
193 'application_type' => null,
194 'client_name' => null,
195 'logo_uri' => null,
196 'redirect_uris' => null,
197 'post_logout_redirect_uris' => null,
198 'token_endpoint_auth_method' => array('client_secret_basic', 'client_secret_post'),
199 'policy_uri' => null,
200 'tos_uri' => null,
201 'jwks_uri' => null,
202 'jwks' => null,
203 'sector_identifier_uri' => null,
204 'subject_type' => array('pairwise', 'public'),
205 'default_max_age' => null,
206 'require_auth_time' => null,
207 'default_acr_values' => null,
208 'initiate_login_uri' => null, // for anything with a SMART 'launch/ehr' context we need to know how to initiate the login
209 'request_uris' => null,
210 'response_types' => null,
211 'grant_types' => null,
212 // info on scope can be seen at
213 // OAUTH2 Dynamic Client Registration RFC 7591 Section 2 Page 9
214 // @see https://tools.ietf.org/html/rfc7591#section-2
215 'scope' => null
217 $client_id = $this->base64url_encode(RandomGenUtils::produceRandomBytes(32));
218 $reg_token = $this->base64url_encode(RandomGenUtils::produceRandomBytes(32));
219 $reg_client_uri_path = $this->base64url_encode(RandomGenUtils::produceRandomBytes(16));
220 $params = array(
221 'client_id' => $client_id,
222 'client_id_issued_at' => time(),
223 'registration_access_token' => $reg_token,
224 'registration_client_uri_path' => $reg_client_uri_path
227 $params['client_role'] = 'patient';
228 // only include secret if a confidential app else force PKCE for native and web apps.
229 $client_secret = '';
230 if ($data['application_type'] === 'private') {
231 $client_secret = $this->base64url_encode(RandomGenUtils::produceRandomBytes(64));
232 $params['client_secret'] = $client_secret;
233 $params['client_role'] = 'user';
235 // don't allow system scopes without a jwk or jwks_uri value
236 if (
237 strpos($data['scope'], 'system/') !== false
238 && empty($data['jwks']) && empty($data['jwks_uri'])
240 throw new OAuthServerException('jwks is invalid', 0, 'invalid_client_metadata');
242 // don't allow user, system scopes, and offline_access for public apps
243 } elseif (
244 strpos($data['scope'], 'system/') !== false
245 || strpos($data['scope'], 'user/') !== false
247 throw new OAuthServerException("system and user scopes are only allowed for confidential clients", 0, 'invalid_client_metadata');
249 $this->validateScopesAgainstServerApprovedScopes($data['scope']);
251 foreach ($keys as $key => $supported_values) {
252 if (isset($data[$key])) {
253 if (in_array($key, array('contacts', 'redirect_uris', 'request_uris', 'post_logout_redirect_uris', 'grant_types', 'response_types', 'default_acr_values'))) {
254 $params[$key] = implode('|', $data[$key]);
255 } elseif ($key === 'jwks') {
256 $params[$key] = json_encode($data[$key]);
257 } else {
258 $params[$key] = $data[$key];
260 if (!empty($supported_values)) {
261 if (!in_array($params[$key], $supported_values)) {
262 throw new OAuthServerException("Unsupported $key value : $params[$key]", 0, 'invalid_client_metadata');
267 if (!isset($data['redirect_uris'])) {
268 throw new OAuthServerException('redirect_uris is invalid', 0, 'invalid_redirect_uri');
270 if (isset($data['post_logout_redirect_uris']) && empty($data['post_logout_redirect_uris'])) {
271 throw new OAuthServerException('post_logout_redirect_uris is invalid', 0, 'invalid_client_metadata');
273 // save to oauth client table
274 $badSave = $this->newClientSave($client_id, $params);
275 if (!empty($badSave)) {
276 throw OAuthServerException::serverError("Try again. Unable to create account");
278 $reg_uri = $this->authBaseFullUrl . '/client/' . $reg_client_uri_path;
279 unset($params['registration_client_uri_path']);
280 $client_json = array(
281 'client_id' => $client_id,
282 'client_secret' => $client_secret,
283 'registration_access_token' => $reg_token,
284 'registration_client_uri' => $reg_uri,
285 'client_id_issued_at' => time(),
286 'client_secret_expires_at' => 0
288 $array_params = array('contacts', 'redirect_uris', 'request_uris', 'post_logout_redirect_uris', 'response_types', 'grant_types', 'default_acr_values');
289 foreach ($array_params as $aparam) {
290 if (isset($params[$aparam])) {
291 $params[$aparam] = explode('|', $params[$aparam]);
294 if (!empty($params['jwks'])) {
295 $params['jwks'] = json_decode($params['jwks'], true);
297 if (isset($params['require_auth_time'])) {
298 $params['require_auth_time'] = ($params['require_auth_time'] === 1);
300 // send response
301 $response->withHeader("Cache-Control", "no-store");
302 $response->withHeader("Pragma", "no-cache");
303 $response->withHeader('Content-Type', 'application/json');
304 $body = $response->getBody();
305 $body->write(json_encode(array_merge($client_json, $params)));
307 SessionUtil::oauthSessionCookieDestroy();
308 $this->emitResponse($response->withStatus(200)->withBody($body));
309 } catch (OAuthServerException $exception) {
310 SessionUtil::oauthSessionCookieDestroy();
311 $this->emitResponse($exception->generateHttpResponse($response));
316 * Verifies that the scope string only has approved scopes for the system.
317 * @param $scopeString the space separated scope string
319 private function validateScopesAgainstServerApprovedScopes($scopeString)
321 $requestScopes = explode(" ", $scopeString);
322 if (empty($requestScopes)) {
323 return;
326 $scopeRepo = new ScopeRepository($this->restConfig);
327 $scopeRepo->setRequestScopes($scopeString);
328 foreach ($requestScopes as $scope) {
329 $validScope = $scopeRepo->getScopeEntityByIdentifier($scope);
330 if (empty($validScope)) {
331 throw OAuthServerException::invalidScope($scope);
336 private function createServerResponse(): ResponseInterface
338 return (new Psr17Factory())->createResponse();
341 private function createServerRequest(): ServerRequestInterface
343 $psr17Factory = new Psr17Factory();
345 return (new ServerRequestCreator(
346 $psr17Factory, // ServerRequestFactory
347 $psr17Factory, // UriFactory
348 $psr17Factory, // UploadedFileFactory
349 $psr17Factory // StreamFactory
350 ))->fromGlobals();
353 public function base64url_encode($data): string
355 return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
358 public function base64url_decode($token)
360 $b64 = strtr($token, '-_', '+/');
361 return base64_decode($b64);
364 public function newClientSave($clientId, $info): bool
366 $user = $_SESSION['authUserID'] ?? null; // future use for provider client.
367 $site = $this->siteId;
368 $is_confidential_client = empty($info['client_secret']) ? 0 : 1;
370 $contacts = $info['contacts'];
371 $redirects = $info['redirect_uris'];
372 $logout_redirect_uris = $info['post_logout_redirect_uris'] ?? null;
373 $info['client_secret'] = $info['client_secret'] ?? null; // just to be sure empty is null;
374 // set our list of default scopes for the registration if our scope is empty
375 // This is how a client can set if they support SMART apps and other stuff by passing in the 'launch'
376 // scope to the dynamic client registration.
377 // per RFC 7591 @see https://tools.ietf.org/html/rfc7591#section-2
378 // TODO: adunsulag do we need to reject the registration if there are certain scopes here we do not support
379 // TODO: adunsulag should we check these scopes against our '$this->supportedScopes'?
380 $info['scope'] = $info['scope'] ?? 'openid email phone address api:oemr api:fhir api:port';
382 $scopes = explode(" ", $info['scope']);
383 $scopeRepo = new ScopeRepository();
385 if ($scopeRepo->hasScopesThatRequireManualApproval($is_confidential_client == 1, $scopes)) {
386 $is_client_enabled = 0; // disabled
387 } else {
388 $is_client_enabled = 1; // enabled
391 // encrypt the client secret
392 if (!empty($info['client_secret'])) {
393 $info['client_secret'] = $this->cryptoGen->encryptStandard($info['client_secret']);
397 try {
398 // TODO: @adunsulag why do we skip over request_uris when we have it in the outer function?
399 $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', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
400 $i_vals = array(
401 $clientId,
402 $info['client_role'],
403 $info['client_name'],
404 $info['client_secret'],
405 $info['registration_access_token'],
406 $info['registration_client_uri_path'],
407 $contacts,
408 $redirects,
409 $info['scope'],
410 $user,
411 $site,
412 $is_confidential_client,
413 $logout_redirect_uris,
414 ($info['jwks_uri'] ?? null),
415 ($info['jwks'] ?? null),
416 ($info['initiate_login_uri'] ?? null),
417 ($info['endorsements'] ?? null),
418 ($info['policy_uri'] ?? null),
419 ($info['tos_uri'] ?? null),
420 $is_client_enabled
423 return sqlQueryNoLog($sql, $i_vals);
424 } catch (\RuntimeException $e) {
425 die($e);
429 public function emitResponse($response): void
431 if (headers_sent()) {
432 throw new RuntimeException('Headers already sent.');
434 $statusLine = sprintf(
435 'HTTP/%s %s %s',
436 $response->getProtocolVersion(),
437 $response->getStatusCode(),
438 $response->getReasonPhrase()
440 header($statusLine, true);
441 foreach ($response->getHeaders() as $name => $values) {
442 $responseHeader = sprintf('%s: %s', $name, $response->getHeaderLine($name));
443 header($responseHeader, false);
445 // send it along.
446 echo $response->getBody();
449 public function clientRegisteredDetails(): void
451 $response = $this->createServerResponse();
453 try {
454 $token = $_REQUEST['access_token'];
455 if (!$token) {
456 $token = $this->getBearerToken();
457 if (!$token) {
458 throw new OAuthServerException('No Access Code', 0, 'invalid_request', 403);
461 $pos = strpos($_SERVER['PATH_INFO'], '/client/');
462 if ($pos === false) {
463 throw new OAuthServerException('Invalid path', 0, 'invalid_request', 403);
465 $uri_path = substr($_SERVER['PATH_INFO'], $pos + 8);
466 $client = sqlQuery("SELECT * FROM `oauth_clients` WHERE `registration_uri_path` = ?", array($uri_path));
467 if (!$client) {
468 throw new OAuthServerException('Invalid client', 0, 'invalid_request', 403);
470 if ($client['registration_access_token'] !== $token) {
471 throw new OAuthServerException('Invalid registration token', 0, 'invalid _request', 403);
473 $params['client_id'] = $client['client_id'];
474 $params['client_secret'] = $this->cryptoGen->decryptStandard($client['client_secret']);
475 $params['contacts'] = explode('|', $client['contacts']);
476 $params['application_type'] = $client['client_role'];
477 $params['client_name'] = $client['client_name'];
478 $params['redirect_uris'] = explode('|', $client['redirect_uri']);
480 $response->withHeader("Cache-Control", "no-store");
481 $response->withHeader("Pragma", "no-cache");
482 $response->withHeader('Content-Type', 'application/json');
483 $body = $response->getBody();
484 $body->write(json_encode($params));
486 SessionUtil::oauthSessionCookieDestroy();
487 $this->emitResponse($response->withStatus(200)->withBody($body));
488 } catch (OAuthServerException $exception) {
489 SessionUtil::oauthSessionCookieDestroy();
490 $this->emitResponse($exception->generateHttpResponse($response));
494 public function getBearerToken(): string
496 $request = $this->createServerRequest();
497 $request_headers = $request->getHeaders();
498 $headers = [];
499 foreach ($request_headers as $header => $value) {
500 $headers[strtolower($header)] = $value[0];
502 $authorization = $headers['authorization'];
503 if ($authorization) {
504 $pieces = explode(' ', $authorization);
505 if (strcasecmp($pieces[0], 'bearer') !== 0) {
506 return "";
509 return rtrim($pieces[1]);
511 return "";
514 public function oauthAuthorizationFlow(): void
516 $this->logger->debug("AuthorizationController->oauthAuthorizationFlow() starting authorization flow");
517 $response = $this->createServerResponse();
518 $request = $this->createServerRequest();
520 if ($nonce = $request->getQueryParams()['nonce'] ?? null) {
521 $_SESSION['nonce'] = $request->getQueryParams()['nonce'];
524 $this->logger->debug("AuthorizationController->oauthAuthorizationFlow() request query params ", ["queryParams" => $request->getQueryParams()]);
526 $this->grantType = 'authorization_code';
527 $server = $this->getAuthorizationServer();
528 try {
529 // Validate the HTTP request and return an AuthorizationRequest object.
530 $this->logger->debug("AuthorizationController->oauthAuthorizationFlow() attempting to validate auth request");
531 $authRequest = $server->validateAuthorizationRequest($request);
532 $this->logger->debug("AuthorizationController->oauthAuthorizationFlow() auth request validated, csrf,scopes,client_id setup");
533 $_SESSION['csrf'] = $authRequest->getState();
534 $_SESSION['scopes'] = $request->getQueryParams()['scope'];
535 $_SESSION['client_id'] = $request->getQueryParams()['client_id'];
536 $_SESSION['client_role'] = $authRequest->getClient()->getClientRole();
537 $_SESSION['launch'] = $request->getQueryParams()['launch'] ?? null;
538 $_SESSION['redirect_uri'] = $authRequest->getRedirectUri() ?? null;
539 $this->logger->debug("AuthorizationController->oauthAuthorizationFlow() session updated", ['session' => $_SESSION]);
540 // If needed, serialize into a users session
541 if ($this->providerForm) {
542 $this->serializeUserSession($authRequest, $request);
543 $this->logger->debug("AuthorizationController->oauthAuthorizationFlow() redirecting to provider form");
544 // call our login then login calls authorize if approved by user
545 header("Location: " . $this->authBaseUrl . "/provider/login", true, 301);
546 exit;
548 } catch (OAuthServerException $exception) {
549 $this->logger->error(
550 "AuthorizationController->oauthAuthorizationFlow() OAuthServerException",
551 ["hint" => $exception->getHint(), "message" => $exception->getMessage()
552 , 'payload' => $exception->getPayload()
553 , 'trace' => $exception->getTraceAsString()
554 , 'redirectUri' => $exception->getRedirectUri()
555 , 'errorType' => $exception->getErrorType()]
557 SessionUtil::oauthSessionCookieDestroy();
558 $this->emitResponse($exception->generateHttpResponse($response));
559 } catch (Exception $exception) {
560 $this->logger->error("AuthorizationController->oauthAuthorizationFlow() Exception message: " . $exception->getMessage());
561 SessionUtil::oauthSessionCookieDestroy();
562 $body = $response->getBody();
563 $body->write($exception->getMessage());
564 $this->emitResponse($response->withStatus(500)->withBody($body));
569 * Retrieve the authorization server with all of the grants configured
570 * TODO: @adunsulag is there a better way to handle skipping the refresh token on the authorization grant?
571 * Due to the way the server is created and the fact we have to skip the refresh token when an offline_scope is passed
572 * for authorization_grant/password_grant. We ignore offline_scope for custom_credentials
573 * @param bool $includeAuthGrantRefreshToken Whether the authorization server should issue a refresh token for an authorization grant.
574 * @return AuthorizationServer
575 * @throws Exception
577 public function getAuthorizationServer($includeAuthGrantRefreshToken = true): AuthorizationServer
579 $protectedClaims = ['profile', 'email', 'address', 'phone'];
580 $scopeRepository = new ScopeRepository($this->restConfig);
581 $claims = $scopeRepository->getSupportedClaims();
582 $customClaim = [];
583 foreach ($claims as $claim) {
584 if (in_array($claim, $protectedClaims, true)) {
585 continue;
587 $customClaim[] = new ClaimSetEntity($claim, [$claim]);
589 if (!empty($_SESSION['nonce'])) {
590 // nonce scope added later. this is for id token nonce claim.
591 $customClaim[] = new ClaimSetEntity('nonce', ['nonce']);
594 // OpenID Connect Response Type
595 $this->logger->debug("AuthorizationController->getAuthorizationServer() creating server");
596 $responseType = new IdTokenSMARTResponse(new IdentityRepository(), new ClaimExtractor($customClaim));
598 if (empty($this->grantType)) {
599 $this->grantType = 'authorization_code';
602 // responseType is cloned inside the league auth server so we have to handle changes here before we send
603 // into the $authServer the $responseType
604 if ($this->grantType === 'authorization_code') {
605 $responseType->markIsAuthorizationGrant(); // we have specific SMART responses for an authorization grant.
608 $authServer = new AuthorizationServer(
609 new ClientRepository(),
610 new AccessTokenRepository(),
611 new ScopeRepository($this->restConfig),
612 new CryptKey($this->privateKey, $this->passphrase),
613 $this->oaEncryptionKey,
614 $responseType
617 $this->logger->debug("AuthorizationController->getAuthorizationServer() grantType is " . $this->grantType);
618 if ($this->grantType === 'authorization_code') {
619 $this->logger->debug(
620 "logging global params",
621 ['site_addr_oath' => $GLOBALS['site_addr_oath'], 'web_root' => $GLOBALS['web_root'], 'site_id' => $_SESSION['site_id']]
623 $fhirServiceConfig = new ServerConfig();
624 $expectedAudience = [
625 $fhirServiceConfig->getFhirUrl(),
626 $GLOBALS['site_addr_oath'] . $GLOBALS['web_root'] . '/apis/' . $_SESSION['site_id'] . "/api",
627 $GLOBALS['site_addr_oath'] . $GLOBALS['web_root'] . '/apis/' . $_SESSION['site_id'] . "/portal",
629 $grant = new CustomAuthCodeGrant(
630 new AuthCodeRepository(),
631 new RefreshTokenRepository($includeAuthGrantRefreshToken),
632 new \DateInterval('PT1M'), // auth code. should be short turn around.
633 $expectedAudience
636 $grant->setRefreshTokenTTL(new \DateInterval('P3M')); // minimum per ONC
637 $authServer->enableGrantType(
638 $grant,
639 new \DateInterval('PT1H') // access token
642 if ($this->grantType === 'refresh_token') {
643 $grant = new CustomRefreshTokenGrant(new RefreshTokenRepository());
644 $grant->setRefreshTokenTTL(new \DateInterval('P3M'));
645 $authServer->enableGrantType(
646 $grant,
647 new \DateInterval('PT1H') // The new access token will expire after 1 hour
650 // TODO: break this up - throw exception for not turned on.
651 if (!empty($GLOBALS['oauth_password_grant']) && ($this->grantType === self::GRANT_TYPE_PASSWORD)) {
652 $grant = new CustomPasswordGrant(
653 new UserRepository(),
654 new RefreshTokenRepository($includeAuthGrantRefreshToken)
656 $grant->setRefreshTokenTTL(new DateInterval('P3M'));
657 $authServer->enableGrantType(
658 $grant,
659 new \DateInterval('PT1H') // access token
662 if ($this->grantType === self::GRANT_TYPE_CLIENT_CREDENTIALS) {
663 // Enable the client credentials grant on the server
664 $client_credentials = new CustomClientCredentialsGrant(AuthorizationController::getAuthBaseFullURL() . AuthorizationController::getTokenPath());
665 $client_credentials->setLogger($this->logger);
666 $client_credentials->setHttpClient(new Client()); // set our guzzle client here
667 $authServer->enableGrantType(
668 $client_credentials,
669 // https://hl7.org/fhir/uv/bulkdata/authorization/index.html#issuing-access-tokens Spec states 5 min max
670 new \DateInterval('PT300S')
674 $this->logger->debug("AuthorizationController->getAuthorizationServer() authServer created");
675 return $authServer;
678 private function serializeUserSession($authRequest, ServerRequestInterface $httpRequest): void
680 $launchParam = isset($httpRequest->getQueryParams()['launch']) ? $httpRequest->getQueryParams()['launch'] : null;
681 // keeping somewhat granular
682 try {
683 $scopes = $authRequest->getScopes();
684 $scoped = [];
685 foreach ($scopes as $scope) {
686 $scoped[] = $scope->getIdentifier();
688 $client['name'] = $authRequest->getClient()->getName();
689 $client['redirectUri'] = $authRequest->getClient()->getRedirectUri();
690 $client['identifier'] = $authRequest->getClient()->getIdentifier();
691 $client['isConfidential'] = $authRequest->getClient()->isConfidential();
692 $outer = array(
693 'grantTypeId' => $authRequest->getGrantTypeId(),
694 'authorizationApproved' => false,
695 'redirectUri' => $authRequest->getRedirectUri(),
696 'state' => $authRequest->getState(),
697 'codeChallenge' => $authRequest->getCodeChallenge(),
698 'codeChallengeMethod' => $authRequest->getCodeChallengeMethod(),
700 $result = array('outer' => $outer, 'scopes' => $scoped, 'client' => $client);
701 $this->authRequestSerial = json_encode($result, JSON_THROW_ON_ERROR);
702 $_SESSION['authRequestSerial'] = $this->authRequestSerial;
703 } catch (Exception $e) {
704 echo $e;
708 public function userLogin(): void
710 $response = $this->createServerResponse();
712 $patientRoleSupport = (!empty($GLOBALS['rest_portal_api']) || !empty($GLOBALS['rest_fhir_api']));
714 if (empty($_POST['username']) && empty($_POST['password'])) {
715 $this->logger->debug("AuthorizationController->userLogin() presenting blank login form");
716 $oauthLogin = true;
717 $redirect = $this->authBaseUrl . "/login";
718 require_once(__DIR__ . "/../../oauth2/provider/login.php");
719 exit();
721 $continueLogin = false;
722 if (isset($_POST['user_role'])) {
723 if (!CsrfUtils::verifyCsrfToken($_POST["csrf_token_form"], 'oauth2')) {
724 $this->logger->error("AuthorizationController->userLogin() Invalid CSRF token");
725 CsrfUtils::csrfNotVerified(false, true, false);
726 unset($_POST['username'], $_POST['password']);
727 $invalid = "Sorry. Invalid CSRF!"; // todo: display error
728 $oauthLogin = true;
729 $redirect = $this->authBaseUrl . "/login";
730 require_once(__DIR__ . "/../../oauth2/provider/login.php");
731 exit();
732 } else {
733 $this->logger->debug("AuthorizationController->userLogin() verifying login information");
734 $continueLogin = $this->verifyLogin($_POST['username'], $_POST['password'], ($_POST['email'] ?? ''), $_POST['user_role']);
735 $this->logger->debug("AuthorizationController->userLogin() verifyLogin result", ["continueLogin" => $continueLogin]);
739 if (!$continueLogin) {
740 $this->logger->debug("AuthorizationController->userLogin() login invalid, presenting login form");
741 $invalid = "Sorry, Invalid!"; // todo: display error
742 $oauthLogin = true;
743 $redirect = $this->authBaseUrl . "/login";
744 require_once(__DIR__ . "/../../oauth2/provider/login.php");
745 exit();
746 } else {
747 $this->logger->debug("AuthorizationController->userLogin() login valid, continuing oauth process");
750 //Require MFA if turned on
751 $mfa = new MfaUtils($this->userId);
752 $mfaToken = $mfa->tokenFromRequest($_POST['mfa_type'] ?? null);
753 $mfaType = $mfa->getType();
754 $TOTP = MfaUtils::TOTP;
755 $U2F = MfaUtils::U2F;
756 if ($_POST['user_role'] === 'api' && $mfa->isMfaRequired() && is_null($mfaToken)) {
757 $oauthLogin = true;
758 $mfaRequired = true;
759 $redirect = $this->authBaseUrl . "/login";
760 if (in_array(MfaUtils::U2F, $mfaType)) {
761 $appId = $mfa->getAppId();
762 $requests = $mfa->getU2fRequests();
764 require_once(__DIR__ . "/../../oauth2/provider/login.php");
765 exit();
767 //Check the validity of the authentication token
768 if ($_POST['user_role'] === 'api' && $mfa->isMfaRequired() && !is_null($mfaToken)) {
769 if (!$mfaToken || !$mfa->check($mfaToken, $_POST['mfa_type'])) {
770 $invalid = "Sorry, Invalid code!";
771 $oauthLogin = true;
772 $mfaRequired = true;
773 $mfaType = $mfa->getType();
774 $redirect = $this->authBaseUrl . "/login";
775 require_once(__DIR__ . "/../../oauth2/provider/login.php");
776 exit();
780 unset($_POST['username'], $_POST['password']);
781 $_SESSION['persist_login'] = isset($_POST['persist_login']) ? 1 : 0;
782 $user = new UserEntity();
783 $user->setIdentifier($_SESSION['user_id']);
784 $_SESSION['claims'] = $user->getClaims();
785 $oauthLogin = true;
786 // need to redirect to patient select if we have a launch context && this isn't a patient login
787 $authorize = 'authorize';
789 // if we need to authorize any smart context as part of our OAUTH handler we do that here
790 // otherwise we send on to our scope authorization confirm.
791 if ($this->smartAuthController->needSmartAuthorization()) {
792 $redirect = $this->authBaseFullUrl . $this->smartAuthController->getSmartAuthorizationPath();
793 } else {
794 $redirect = $this->authBaseFullUrl . self::ENDPOINT_SCOPE_AUTHORIZE_CONFIRM;
796 $this->logger->debug("AuthorizationController->userLogin() complete redirecting", ["scopes" => $_SESSION['scopes']
797 , 'claims' => $_SESSION['claims'], 'redirect' => $redirect]);
799 header("Location: $redirect");
800 exit;
803 public function scopeAuthorizeConfirm()
805 // TODO: @adunsulag if there are no scopes or claims here we probably want to show an error...
807 // TODO: @adunsulag this is also where we want to show a special message if the offline scope is present.
809 // show our scope auth piece
810 $oauthLogin = true;
811 $redirect = $this->authBaseUrl . "/device/code";
812 $scopeString = $_SESSION['scopes'] ?? '';
813 // check for offline_access
815 $scopesList = explode(' ', $scopeString);
816 $offline_requested = false;
817 $scopes = [];
818 foreach ($scopesList as $scope) {
819 if ($scope !== self::OFFLINE_ACCESS_SCOPE) {
820 $scopes[] = $scope;
821 } else {
822 $offline_requested = true;
825 $offline_access_date = (new DateTimeImmutable())->add(new \DateInterval("P3M"))->format("Y-m-d");
828 $claims = $_SESSION['claims'] ?? [];
830 $clientRepository = new ClientRepository();
831 $client = $clientRepository->getClientEntity($_SESSION['client_id']);
832 $clientName = "<" . xl("Client Name Not Found") . ">";
833 if (!empty($client)) {
834 $clientName = $client->getName();
837 $uuidToUser = new UuidUserAccount($_SESSION['user_id']);
838 $userRole = $uuidToUser->getUserRole();
839 $userAccount = $uuidToUser->getUserAccount();
840 require_once(__DIR__ . "/../../oauth2/provider/scope-authorize.php");
844 * Checks if we are in a SMART authorization endpoint
845 * @param $end_point
846 * @return bool
848 public function isSMARTAuthorizationEndPoint($end_point)
850 return $this->smartAuthController->isValidRoute($end_point);
854 * Route handler for any SMART authorization contexts that we need for OpenEMR
855 * @param $end_point
857 public function dispatchSMARTAuthorizationEndpoint($end_point)
859 return $this->smartAuthController->dispatchRoute($end_point);
862 private function verifyLogin($username, $password, $email = '', $type = 'api'): bool
864 $auth = new AuthUtils($type);
865 $is_true = $auth->confirmPassword($username, $password, $email);
866 if (!$is_true) {
867 $this->logger->debug("AuthorizationController->verifyLogin() login attempt failed", ['username' => $username, 'email' => $email, 'type' => $type]);
868 return false;
870 // TODO: should user_id be set to be a uuid here?
871 if ($this->userId = $auth->getUserId()) {
872 $_SESSION['user_id'] = $this->getUserUuid($this->userId, 'users');
873 $this->logger->debug("AuthorizationController->verifyLogin() user login", ['user_id' => $_SESSION['user_id'],
874 'username' => $username, 'email' => $email, 'type' => $type]);
875 return true;
877 if ($id = $auth->getPatientId()) {
878 $puuid = $this->getUserUuid($id, 'patient');
879 // TODO: @adunsulag check with @sjpadgett on where this user_id is even used as we are assigning it to be a uuid
880 $_SESSION['user_id'] = $puuid;
881 $this->logger->debug("AuthorizationController->verifyLogin() patient login", ['pid' => $_SESSION['user_id']
882 , 'username' => $username, 'email' => $email, 'type' => $type]);
883 $_SESSION['pid'] = $id;
884 $_SESSION['puuid'] = $puuid;
885 return true;
888 return false;
891 protected function getUserUuid($userId, $userRole): string
893 switch ($userRole) {
894 case 'users':
895 UuidRegistry::createMissingUuidsForTables(['users']);
896 $account_sql = "SELECT `uuid` FROM `users` WHERE `id` = ?";
897 break;
898 case 'patient':
899 UuidRegistry::createMissingUuidsForTables(['patient_data']);
900 $account_sql = "SELECT `uuid` FROM `patient_data` WHERE `pid` = ?";
901 break;
902 default:
903 return '';
905 $id = sqlQueryNoLog($account_sql, array($userId))['uuid'];
907 return UuidRegistry::uuidToString($id);
911 * Note this corresponds with the /auth/code endpoint
913 public function authorizeUser(): void
915 $this->logger->debug("AuthorizationController->authorizeUser() starting authorization");
916 $response = $this->createServerResponse();
917 $authRequest = $this->deserializeUserSession();
918 try {
919 $authRequest = $this->updateAuthRequestWithUserApprovedScopes($authRequest, $_POST['scope']);
920 $include_refresh_token = $this->shouldIncludeRefreshTokenForScopes($authRequest->getScopes());
921 $server = $this->getAuthorizationServer($include_refresh_token);
922 $user = new UserEntity();
923 $user->setIdentifier($_SESSION['user_id']);
924 $authRequest->setUser($user);
925 $authRequest->setAuthorizationApproved(true);
926 $result = $server->completeAuthorizationRequest($authRequest, $response);
927 $redirect = $result->getHeader('Location')[0];
928 $authorization = parse_url($redirect, PHP_URL_QUERY);
929 // stash appropriate session for token endpoint.
930 unset($_SESSION['authRequestSerial']);
931 unset($_SESSION['claims']);
932 $csrf_private_key = $_SESSION['csrf_private_key']; // switcheroo so this does not end up in the session cache
933 unset($_SESSION['csrf_private_key']);
934 $session_cache = json_encode($_SESSION, JSON_THROW_ON_ERROR);
935 $_SESSION['csrf_private_key'] = $csrf_private_key;
936 unset($csrf_private_key);
937 $code = [];
938 // parse scope as also a query param if needed
939 parse_str($authorization, $code);
940 $code = $code["code"];
941 if (isset($_POST['proceed']) && !empty($code) && !empty($session_cache)) {
942 if (!CsrfUtils::verifyCsrfToken($_POST["csrf_token_form"], 'oauth2')) {
943 CsrfUtils::csrfNotVerified(false, true, false);
944 throw OAuthServerException::serverError("Failed authorization due to failed CSRF check.");
945 } else {
946 $this->saveTrustedUser($_SESSION['client_id'], $_SESSION['user_id'], $_SESSION['scopes'], $_SESSION['persist_login'], $code, $session_cache);
948 } else {
949 if (empty($_SESSION['csrf'])) {
950 throw OAuthServerException::serverError("Failed authorization due to missing data.");
953 // Return the HTTP redirect response. Redirect is to client callback.
954 $this->logger->debug("AuthorizationController->authorizeUser() sending server response");
955 SessionUtil::oauthSessionCookieDestroy();
956 $this->emitResponse($result);
957 exit;
958 } catch (Exception $exception) {
959 $this->logger->error("AuthorizationController->authorizeUser() Exception thrown", ["message" => $exception->getMessage()]);
960 SessionUtil::oauthSessionCookieDestroy();
961 $body = $response->getBody();
962 $body->write($exception->getMessage());
963 $this->emitResponse($response->withStatus(500)->withBody($body));
968 * @param ScopeEntityInterface[] $scopes
970 private function shouldIncludeRefreshTokenForScopes(array $scopes)
972 foreach ($scopes as $scope) {
973 if ($scope->getIdentifier() == self::OFFLINE_ACCESS_SCOPE) {
974 return true;
977 return false;
980 private function updateAuthRequestWithUserApprovedScopes(AuthorizationRequest $request, $approvedScopes)
982 $this->logger->debug(
983 "AuthorizationController->updateAuthRequestWithUserApprovedScopes() attempting to update auth request with user approved scopes",
984 ['userApprovedScopes' => $approvedScopes ]
986 $requestScopes = $request->getScopes();
987 $scopeUpdates = [];
988 // we only allow scopes from the original session request, if user approved scope it will show up here.
989 foreach ($requestScopes as $scope) {
990 if (isset($approvedScopes[$scope->getIdentifier()])) {
991 $scopeUpdates[] = $scope;
994 $this->logger->debug(
995 "AuthorizationController->updateAuthRequestWithUserApprovedScopes() replaced request scopes with user approved scopes",
996 ['updatedScopes' => $scopeUpdates]
999 $request->setScopes($scopeUpdates);
1000 return $request;
1003 private function deserializeUserSession(): AuthorizationRequest
1005 $authRequest = new AuthorizationRequest();
1006 try {
1007 $requestData = $_SESSION['authRequestSerial'] ?? $this->authRequestSerial;
1008 $restore = json_decode($requestData, true, 512);
1009 $outer = $restore['outer'];
1010 $client = $restore['client'];
1011 $scoped = $restore['scopes'];
1012 $authRequest->setGrantTypeId($outer['grantTypeId']);
1013 $e = new ClientEntity();
1014 $e->setName($client['name']);
1015 $e->setRedirectUri($client['redirectUri']);
1016 $e->setIdentifier($client['identifier']);
1017 $e->setIsConfidential($client['isConfidential']);
1018 $authRequest->setClient($e);
1019 $scopes = [];
1020 foreach ($scoped as $scope) {
1021 $s = new ScopeEntity();
1022 $s->setIdentifier($scope);
1023 $scopes[] = $s;
1025 $authRequest->setScopes($scopes);
1026 $authRequest->setAuthorizationApproved($outer['authorizationApproved']);
1027 $authRequest->setRedirectUri($outer['redirectUri']);
1028 $authRequest->setState($outer['state']);
1029 $authRequest->setCodeChallenge($outer['codeChallenge']);
1030 $authRequest->setCodeChallengeMethod($outer['codeChallengeMethod']);
1031 } catch (Exception $e) {
1032 echo $e;
1035 return $authRequest;
1039 * Note this corresponds with the /token endpoint
1041 public function oauthAuthorizeToken(): void
1043 $this->logger->debug("AuthorizationController->oauthAuthorizeToken() starting request");
1044 $response = $this->createServerResponse();
1045 $request = $this->createServerRequest();
1047 // authorization code which is normally only sent for new tokens
1048 // by the authorization grant flow.
1049 $code = $request->getParsedBody()['code'] ?? null;
1050 // grantType could be authorization_code, password or refresh_token.
1051 $this->grantType = $request->getParsedBody()['grant_type'];
1052 $this->logger->debug("AuthorizationController->oauthAuthorizeToken() grant type received", ['grant_type' => $this->grantType]);
1053 if ($this->grantType === 'authorization_code') {
1054 // re-populate from saved session cache populated in authorizeUser().
1055 $ssbc = $this->sessionUserByCode($code);
1056 $_SESSION = json_decode($ssbc['session_cache'], true);
1057 $this->logger->debug("AuthorizationController->oauthAuthorizeToken() restored session user from code ", ['session' => $_SESSION]);
1059 // TODO: explore why we create the request again...
1060 if ($this->grantType === 'refresh_token') {
1061 $request = $this->createServerRequest();
1063 // Finally time to init the server.
1064 $server = $this->getAuthorizationServer();
1065 try {
1066 if (($this->grantType === 'authorization_code') && empty($_SESSION['csrf'])) {
1067 // the saved session was not populated as expected
1068 $this->logger->error("AuthorizationController->oauthAuthorizeToken() CSRF check failed");
1069 throw new OAuthServerException('Bad request', 0, 'invalid_request', 400);
1071 $result = $server->respondToAccessTokenRequest($request, $response);
1072 // save a password trusted user
1073 if ($this->grantType === self::GRANT_TYPE_PASSWORD) {
1074 $this->saveTrustedUserForPasswordGrant($result);
1076 SessionUtil::oauthSessionCookieDestroy();
1077 $this->emitResponse($result);
1078 } catch (OAuthServerException $exception) {
1079 $this->logger->debug(
1080 "AuthorizationController->oauthAuthorizeToken() OAuthServerException occurred",
1081 ["hint" => $exception->getHint(), "message" => $exception->getMessage(), "stack" => $exception->getTraceAsString()]
1083 SessionUtil::oauthSessionCookieDestroy();
1084 $this->emitResponse($exception->generateHttpResponse($response));
1085 } catch (Exception $exception) {
1086 $this->logger->error(
1087 "AuthorizationController->oauthAuthorizeToken() Exception occurred",
1088 ["message" => $exception->getMessage(), 'trace' => $exception->getTraceAsString()]
1090 SessionUtil::oauthSessionCookieDestroy();
1091 $body = $response->getBody();
1092 $body->write($exception->getMessage());
1093 $this->emitResponse($response->withStatus(500)->withBody($body));
1097 public function trustedUser($clientId, $userId)
1099 return $this->trustedUserService->getTrustedUser($clientId, $userId);
1102 public function sessionUserByCode($code)
1104 return $this->trustedUserService->getTrustedUserByCode($code);
1107 public function saveTrustedUser($clientId, $userId, $scope, $persist, $code = '', $session = '', $grant = 'authorization_code')
1109 return $this->trustedUserService->saveTrustedUser($clientId, $userId, $scope, $persist, $code, $session, $grant);
1112 public function decodeToken($token)
1114 return json_decode($this->base64url_decode($token), true);
1117 public function userSessionLogout(): void
1119 $message = '';
1120 $response = $this->createServerResponse();
1121 try {
1122 $id_token = $_REQUEST['id_token_hint'] ?? '';
1123 if (empty($id_token)) {
1124 throw new OAuthServerException('Id token missing from request', 0, 'invalid _request', 400);
1126 $post_logout_url = $_REQUEST['post_logout_redirect_uri'] ?? '';
1127 $state = $_REQUEST['state'] ?? '';
1128 $token_parts = explode('.', $id_token);
1129 $id_payload = $this->decodeToken($token_parts[1]);
1131 $client_id = $id_payload['aud'];
1132 $user = $id_payload['sub'];
1133 $id_nonce = $id_payload['nonce'] ?? '';
1134 $trustedUser = $this->trustedUser($client_id, $user);
1135 if (empty($trustedUser['id'])) {
1136 // not logged in so just continue as if were.
1137 $message = xlt("You are currently not signed in.");
1138 if (!empty($post_logout_url)) {
1139 SessionUtil::oauthSessionCookieDestroy();
1140 header('Location:' . $post_logout_url . "?state=$state");
1141 } else {
1142 SessionUtil::oauthSessionCookieDestroy();
1143 die($message);
1145 exit;
1147 $session_nonce = json_decode($trustedUser['session_cache'], true)['nonce'] ?? '';
1148 // this should be enough to confirm valid id
1149 if ($session_nonce !== $id_nonce) {
1150 throw new OAuthServerException('Id token not issued from this server', 0, 'invalid _request', 400);
1152 // clear the users session
1153 $rtn = $this->trustedUserService->deleteTrustedUserById($trustedUser['id']);
1154 $client = sqlQueryNoLog("SELECT logout_redirect_uris as valid FROM `oauth_clients` WHERE `client_id` = ? AND `logout_redirect_uris` = ?", array($client_id, $post_logout_url));
1155 if (!empty($post_logout_url) && !empty($client['valid'])) {
1156 SessionUtil::oauthSessionCookieDestroy();
1157 header('Location:' . $post_logout_url . "?state=$state");
1158 } else {
1159 $message = xlt("You have been signed out. Thank you.");
1160 SessionUtil::oauthSessionCookieDestroy();
1161 die($message);
1163 } catch (OAuthServerException $exception) {
1164 SessionUtil::oauthSessionCookieDestroy();
1165 $this->emitResponse($exception->generateHttpResponse($response));
1169 public function tokenIntrospection(): void
1171 $response = $this->createServerResponse();
1172 $response->withHeader("Cache-Control", "no-store");
1173 $response->withHeader("Pragma", "no-cache");
1174 $response->withHeader('Content-Type', 'application/json');
1176 $rawToken = $_REQUEST['token'] ?? null;
1177 $token_hint = $_REQUEST['token_type_hint'] ?? null;
1178 $clientId = $_REQUEST['client_id'] ?? null;
1179 // not required for public apps but mandatory for confidential
1180 $clientSecret = $_REQUEST['client_secret'] ?? null;
1182 $this->logger->debug(
1183 self::class . "->tokenIntrospection() start",
1184 ['token_type_hint' => $token_hint, 'client_id' => $clientId]
1187 // the ride starts. had to use a try because PHP doesn't support tryhard yet!
1188 try {
1189 // so regardless of client type(private/public) we need client for client app type and secret.
1190 $client = sqlQueryNoLog("SELECT * FROM `oauth_clients` WHERE `client_id` = ?", array($clientId));
1191 if (empty($client)) {
1192 throw new OAuthServerException('Not a registered client', 0, 'invalid_request', 401);
1194 // a no no. if private we need a secret.
1195 if (empty($clientSecret) && !empty($client['is_confidential'])) {
1196 throw new OAuthServerException('Invalid client app type', 0, 'invalid_request', 400);
1198 // lets verify secret to prevent bad guys.
1199 if (intval($client['is_enabled'] !== 1)) {
1200 // client is disabled and we don't allow introspection of tokens for disabled clients.
1201 throw new OAuthServerException('Client failed security', 0, 'invalid_request', 401);
1203 // lets verify secret to prevent bad guys.
1204 if (!empty($client['client_secret'])) {
1205 $decryptedSecret = $this->cryptoGen->decryptStandard($client['client_secret']);
1206 if ($decryptedSecret !== $clientSecret) {
1207 throw new OAuthServerException('Client failed security', 0, 'invalid_request', 401);
1210 $jsonWebKeyParser = new JsonWebKeyParser($this->oaEncryptionKey, $this->publicKey);
1211 // will try hard to go on if missing token hint. this is to help with universal conformance.
1212 if (empty($token_hint)) {
1213 $token_hint = $jsonWebKeyParser->getTokenHintFromToken($rawToken);
1214 } elseif (($token_hint !== 'access_token' && $token_hint !== 'refresh_token') || empty($rawToken)) {
1215 throw new OAuthServerException('Missing token or unsupported hint.', 0, 'invalid_request', 400);
1218 // are we there yet! client's okay but, is token?
1219 if ($token_hint === 'access_token') {
1220 try {
1221 $result = $jsonWebKeyParser->parseAccessToken($rawToken);
1222 $result['client_id'] = $clientId;
1223 $trusted = $this->trustedUser($result['client_id'], $result['sub']);
1224 if (empty($trusted['id'])) {
1225 $result['active'] = false;
1226 $result['status'] = 'revoked';
1228 $tokenRepository = new AccessTokenRepository();
1229 if ($tokenRepository->isAccessTokenRevokedInDatabase($result['jti'])) {
1230 $result['active'] = false;
1231 $result['status'] = 'revoked';
1233 $audience = $result['aud'];
1234 if (!empty($audience)) {
1235 // audience is an array... we will only validate against the first item
1236 $audience = current($audience);
1238 if ($audience !== $clientId) {
1239 // return no info in this case. possible Phishing
1240 $result = array('active' => false);
1242 } catch (Exception $exception) {
1243 // JWT couldn't be parsed
1244 $body = $response->getBody();
1245 $body->write($exception->getMessage());
1246 SessionUtil::oauthSessionCookieDestroy();
1247 $this->emitResponse($response->withStatus(400)->withBody($body));
1248 exit();
1251 if ($token_hint === 'refresh_token') {
1252 try {
1253 // client_id comes back from the parsed refresh token
1254 $result = $jsonWebKeyParser->parseRefreshToken($rawToken);
1255 } catch (Exception $exception) {
1256 $body = $response->getBody();
1257 $body->write($exception->getMessage());
1258 SessionUtil::oauthSessionCookieDestroy();
1259 $this->emitResponse($response->withStatus(400)->withBody($body));
1260 exit();
1262 $trusted = $this->trustedUser($result['client_id'], $result['sub']);
1263 if (empty($trusted['id'])) {
1264 $result['active'] = false;
1265 $result['status'] = 'revoked';
1267 $tokenRepository = new RefreshTokenRepository();
1268 if ($tokenRepository->isRefreshTokenRevoked($result['jti'])) {
1269 $result['active'] = false;
1270 $result['status'] = 'revoked';
1272 if ($result['client_id'] !== $clientId) {
1273 // return no info in this case. possible Phishing
1274 $result = array('active' => false);
1277 } catch (OAuthServerException $exception) {
1278 // JWT couldn't be parsed
1279 SessionUtil::oauthSessionCookieDestroy();
1280 $this->logger->errorLogCaller($exception->getMessage(), ['trace' => $exception->getTraceAsString()]);
1281 $this->emitResponse($exception->generateHttpResponse($response));
1282 exit();
1284 // we're here so emit results to interface thank you very much.
1285 $body = $response->getBody();
1286 $body->write(json_encode($result));
1287 SessionUtil::oauthSessionCookieDestroy();
1288 $this->emitResponse($response->withStatus(200)->withBody($body));
1289 exit();
1293 * Returns the authentication server token Url endpoint
1294 * @return string
1296 public function getTokenUrl()
1298 return $this->authBaseFullUrl . self::getTokenPath();
1302 * Returns the path prefix that the token authorization endpoint is on.
1303 * @return string
1305 public static function getTokenPath()
1307 return "/token";
1311 * Returns the authentication server manage url
1312 * @return string
1314 public function getManageUrl()
1316 return $this->authBaseFullUrl . self::getManagePath();
1320 * Returns the path prefix that the manage token authorization endpoint is on.
1321 * @return string
1323 public static function getManagePath()
1325 return "/manage";
1329 * Returns the authentication server authorization url to use for oauth authentication
1330 * @return string
1332 public function getAuthorizeUrl()
1334 return $this->authBaseFullUrl . self::getAuthorizePath();
1338 * Returns the path prefix that the authorization endpoint is on.
1339 * @return string
1341 public static function getAuthorizePath()
1343 return "/authorize";
1347 * Returns the authentication server registration url to use for client app / api registration
1348 * @return string
1350 public function getRegistrationUrl()
1352 return $this->authBaseFullUrl . self::getRegistrationPath();
1356 * Returns the path prefix that the registration endpoint is on.
1357 * @return string
1359 public static function getRegistrationPath()
1361 return "/registration";
1365 * Returns the authentication server introspection url to use for checking tokens
1366 * @return string
1368 public function getIntrospectionUrl()
1370 return $this->authBaseFullUrl . self::getIntrospectionPath();
1374 * Returns the path prefix that the introspection endpoint is on.
1375 * @return string
1377 public static function getIntrospectionPath()
1379 return "/introspect";
1383 public static function getAuthBaseFullURL()
1385 $baseUrl = $GLOBALS['webroot'] . '/oauth2/' . $_SESSION['site_id'];
1386 // collect full url and issuing url by using 'site_addr_oath' global
1387 $authBaseFullURL = $GLOBALS['site_addr_oath'] . $baseUrl;
1388 return $authBaseFullURL;
1392 * Given a password grant response, save the trusted user information to the database so password grant users
1393 * can proceed.
1394 * @param ServerResponseInterface $result
1396 private function saveTrustedUserForPasswordGrant(ResponseInterface $result)
1398 $body = $result->getBody();
1399 $body->rewind();
1400 // yep, even password grant gets one. could be useful.
1401 $code = json_decode($body->getContents(), true, 512, JSON_THROW_ON_ERROR)['id_token'];
1402 unset($_SESSION['csrf_private_key']); // gotta remove since binary and will break json_encode (not used for password granttype, so ok to remove)
1403 $session_cache = json_encode($_SESSION, JSON_THROW_ON_ERROR);
1404 $this->saveTrustedUser($_REQUEST['client_id'], $_SESSION['pass_user_id'], $_REQUEST['scope'], 0, $code, $session_cache, self::GRANT_TYPE_PASSWORD);