Fixes #6444, #6419 oauth2 redirect, imports (#6445)
[openemr.git] / src / Common / Auth / OpenIDConnect / Grant / CustomClientCredentialsGrant.php
blob5303a16eecc7ba80dbe4e8637de710a03a3b4524
1 <?php
3 /**
4 * CustomClientCredentialsGrant implements the requirements for ONC SMART Bulk FHIR Client Credentials grant.
5 * @package openemr
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\Common\Auth\OpenIDConnect\Grant;
14 use DateInterval;
15 use Lcobucci\Clock\SystemClock;
16 use Lcobucci\JWT\Configuration;
17 use Lcobucci\JWT\Encoding\CannotDecodeContent;
18 use Lcobucci\JWT\Signer\Key\InMemory;
19 use Lcobucci\JWT\Signer\Rsa\Sha384;
20 use Lcobucci\JWT\Token;
21 use Lcobucci\JWT\Token\InvalidTokenStructure;
22 use Lcobucci\JWT\Token\UnsupportedHeaderFound;
23 use Lcobucci\JWT\Token\Plain;
24 use Lcobucci\JWT\Validation\Constraint\IssuedBy;
25 use Lcobucci\JWT\Validation\Constraint\PermittedFor;
26 use Lcobucci\JWT\Validation\Constraint\SignedWith;
27 use Lcobucci\JWT\Validation\Constraint\ValidAt;
28 use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
29 use League\OAuth2\Server\Entities\ClientEntityInterface;
30 use League\OAuth2\Server\Entities\ScopeEntityInterface;
31 use League\OAuth2\Server\Exception\OAuthServerException;
32 use League\OAuth2\Server\Grant\ClientCredentialsGrant;
33 use League\OAuth2\Server\RequestEvent;
34 use OpenEMR\Common\Auth\OpenIDConnect\Entities\ClientEntity;
35 use OpenEMR\Common\Auth\OpenIDConnect\JWT\JsonWebKeySet;
36 use OpenEMR\Common\Auth\OpenIDConnect\JWT\JWKValidatorException;
37 use OpenEMR\Common\Auth\OpenIDConnect\JWT\RsaSha384Signer;
38 use OpenEMR\Common\Auth\OpenIDConnect\JWT\Validation\UniqueID;
39 use OpenEMR\Common\Auth\OpenIDConnect\Repositories\JWTRepository;
40 use OpenEMR\Common\Database\SqlQueryException;
41 use OpenEMR\Common\Logging\SystemLogger;
42 use OpenEMR\Services\TrustedUserService;
43 use OpenEMR\Services\UserService;
44 use Psr\Http\Client\ClientInterface;
45 use Psr\Http\Message\ServerRequestInterface;
46 use Psr\Log\LoggerInterface;
48 class CustomClientCredentialsGrant extends ClientCredentialsGrant
50 /**
51 * @var LoggerInterface
53 private $logger;
55 /**
56 * The http client that retrieves JWK URIs
57 * @var ClientInterface
59 private $httpClient;
61 /**
62 * @var string The OAUTH2 token issuing url to be used as the audience parameter for JWT validation
64 private $authTokenUrl;
66 /**
67 * @var TrustedUserService
69 private $trustedUserService;
71 /**
72 * @var UserService
74 private $userService;
76 /**
77 * @var JWTRepository
79 private $jwtRepository;
81 /**
82 * The required value for the jwt assertion type
84 const OAUTH_JWT_CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
86 /**
87 * CustomClientCredentialsGrant constructor.
88 * @param $authTokenUrl string The OAUTH2 token issuing url to be used as the audience parameter for JWT validation
90 public function __construct($authTokenUrl)
92 $this->logger = new SystemLogger(); // default if we don't have one.
93 $this->authTokenUrl = $authTokenUrl;
94 $this->trustedUserService = new TrustedUserService();
95 $this->userService = new UserService();
96 $this->jwtRepository = new JWTRepository();
99 /**
100 * @return LoggerInterface
102 public function getLogger(): LoggerInterface
104 return $this->logger;
108 * @param LoggerInterface $logger
110 public function setLogger(LoggerInterface $logger): void
112 $this->logger = $logger;
116 * Allows the http client that retrieves jwks to be overriden. Useful for unit testing
117 * @param ClientInterface $client
119 public function setHttpClient(ClientInterface $client)
121 $this->httpClient = $client;
125 * Allows the http client that retrieves jwks to be overriden. Useful for unit testing
126 * @return ClientInterface
128 public function getHttpClient(): ClientInterface
130 return $this->httpClient;
134 * @return UserService
136 public function getUserService(): UserService
138 return $this->userService;
142 * @param UserService $userService
144 public function setUserService(UserService $userService): void
146 $this->userService = $userService;
150 * @return JWTRepository
152 public function getJwtRepository(): JWTRepository
154 return $this->jwtRepository;
158 * @param JWTRepository $jwtRepository
160 public function setJwtRepository(JWTRepository $jwtRepository): void
162 $this->jwtRepository = $jwtRepository;
166 * We issue an access token, but we force the user account to be our OpenEMR API system user. We also save off the
167 * grant as a TrustedUser which we can use later for revocation if necessary.
168 * @param DateInterval $accessTokenTTL
169 * @param ClientEntityInterface $client
170 * @param string|null $userIdentifier
171 * @param array $scopes
172 * @return \League\OAuth2\Server\Entities\AccessTokenEntityInterface
173 * @throws OAuthServerException If there is a server error, or some other oauth2 violation
174 * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException
176 protected function issueAccessToken(DateInterval $accessTokenTTL, ClientEntityInterface $client, $userIdentifier, array $scopes = [])
178 // let's grab our user id here.
179 if ($userIdentifier === null) {
180 // we want to grab our system user
181 $systemUser = $this->userService->getSystemUser();
182 if (empty($systemUser['uuid'])) {
183 $this->logger->error("SystemUser was missing. System is not setup properly");
184 throw OAuthServerException::serverError("Server was not properly setup");
186 $userIdentifier = $systemUser['uuid'] ?? null;
188 $accessToken = parent::issueAccessToken($accessTokenTTL, $client, $userIdentifier, $scopes); // TODO: Change the autogenerated stub
190 // gotta remove since binary and will break json_encode (not used for password granttype, so ok to remove)
191 unset($_SESSION['csrf_private_key']);
193 $session_cache = json_encode($_SESSION, JSON_THROW_ON_ERROR);
194 $code = null; // code is used only in authorization_code grant types
196 $scopeList = [];
198 foreach ($scopes as $scope) {
199 if ($scope instanceof ScopeEntityInterface) {
200 $scopeList[] = $scope->getIdentifier();
204 // we can't get past the api dispatcher without having a trusted user.
205 $this->trustedUserService->saveTrustedUser(
206 $client->getIdentifier(),
207 $userIdentifier,
208 implode(" ", $scopeList),
210 $code,
211 $session_cache,
212 $this->getIdentifier()
215 return $accessToken;
219 * Gets the client credentials from the request from the request body or
220 * the Http Basic Authorization header
222 * @param ServerRequestInterface $request
224 * @return array
226 protected function getClientCredentials(ServerRequestInterface $request)
228 $this->logger->debug("CustomClientCredentialsGrant->getClientCredentials() inside request");
229 // @see https://tools.ietf.org/html/rfc7523#section-2.2
230 $assertionType = $this->getRequestParameter('client_assertion_type', $request, null);
231 if ($assertionType === 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer') {
232 $this->logger->debug("CustomClientCredentialsGrant->getClientCredentials() client_assertion_type of jwt-bearer. Attempting to retrieve client id");
233 $jwtToken = $this->getJWTFromRequest($request);
234 // see if we can grab the client from here.
236 try {
237 // we skip validation so that we can grab our client id, from there we can hit the database to find our
238 // JWK to validate against.
239 $configuration = Configuration::forUnsecuredSigner(
240 // You may also override the JOSE encoder/decoder if needed by providing extra arguments here
243 $token = $configuration->parser()->parse($jwtToken);
245 assert($token instanceof Plain);
246 $claims = $token->claims(); // Retrieves the token claims
247 if ($claims->has('sub')) { // no subject means invalid client...
248 $this->logger->debug("CustomClientCredentialsGrant->getClientCredentials() jwt token parsed. Client id is ", [$claims->get('sub')]);
249 return [$claims->get('sub')];
251 } catch (CannotDecodeContent | InvalidTokenStructure | UnsupportedHeaderFound $exception) {
252 $this->logger->error(
253 "CustomClientCredentialsGrant->getClientCredentials() failed to parse token",
254 ['exceptionMessage' => $exception->getMessage()]
256 throw OAuthServerException::invalidClient($request);
258 } else {
259 throw OAuthServerException::invalidRequest("client_assertion_type", "assertion type is not supported");
261 return null;
265 * Validate the client against the client's jwks
266 * @see https://tools.ietf.org/html/rfc7523#section-3
268 * @param ServerRequestInterface $request
270 * @throws OAuthServerException
272 * @return ClientEntityInterface
274 protected function validateClient(ServerRequestInterface $request)
276 // skip everything else for now.
277 list($clientId) = $this->getClientCredentials($request);
280 // grab the client
281 $client = $this->getClientEntityOrFail($clientId, $request);
283 // Currently all the JWK validation stuff is centralized in this
284 // grant... but knowledge of the client entity is inside the ClientRepository, either way I don't like the
285 // class cohesion problems this creates.
286 if (!($client instanceof ClientEntity)) {
287 $this->logger->error("CustomClientCredentialsGrant->validateClient() client returned was not a valid ClientEntity ", ['client' => $clientId]);
288 throw OAuthServerException::invalidClient($request);
291 if (!$client->isEnabled()) {
292 $this->logger->error("CustomClientCredentialsGrant->validateClient() client returned was not enabled", ['client' => $clientId]);
293 throw OAuthServerException::invalidClient($request);
296 // validate everything to do with the JWT...
298 // @see https://tools.ietf.org/html/rfc7523#section-3
300 // 1. iss claim required && must match iss URI of sender application
301 // 2.B sub claim required && must match client id
302 // 3. aud claim required && must match this oauth2 server. Token endpoint may be used. This value needs to
303 // be communicated out of band to registering applications (put in SMART App registration page
304 // 4. exp claim required && must be > (current time - skew time[60seconds]).
305 // OPTIONAL choice reject extended period exp claim. OpenEMR choice set to 24 hours
306 // 5. nbf claim if sent must be < (current time) or REJECT
307 // 6. iat claim if sent may be rejected. OpenEMR choice set to 5 minutes
308 // 7. jti claim represents id of the web token, may be stored and checked against to prevent replay attacks
309 // 8. has other claims
310 // 9. MAC signature verification or REJECT
311 // MAC signature needs to use ES384 or RS384 signature verification @see https://tools.ietf.org/html/rfc7518
313 // 10. Reject any other invalid JWT per RFC 7519
315 // @see https://tools.ietf.org/html/rfc7523#section-3.2
316 // IF ERROR set "error" parameter to "invalid_client" use "error_description" or "error_uri" to provide error
317 // information
318 $jwtRepository = $this->getJwtRepository();
319 $token = null;
320 try {
321 // http client required for fetching jwks from the jwks uri and makes unit testing easier
322 $jsonWebKeySet = new JsonWebKeySet($this->getHttpClient(), $client->getJwksUri(), $client->getJwks());
325 $configuration = Configuration::forUnsecuredSigner();
326 $configuration->setValidationConstraints(
327 // we only allow 1 minute drift (note the 'T' specifier here, super important as we want to do time
328 // we had a bug here where P1M was a 1 month drift which is BAD.
329 new ValidAt(new SystemClock(new \DateTimeZone(\date_default_timezone_get())), new \DateInterval('PT1M')),
330 new SignedWith(new RsaSha384Signer(), $jsonWebKeySet),
331 new IssuedBy($client->getIdentifier()),
332 new PermittedFor($this->authTokenUrl), // allowed audience
333 new UniqueID($jwtRepository)
336 // Attempt to parse and validate the JWT
337 $jwt = $this->getJWTFromRequest($request);
338 // issuer = issue URI of sender application so redirectUri
339 // subject claim
340 $token = $configuration->parser()->parse($jwt);
341 $this->logger->debug(
342 "Token parsed",
343 ['claims' => $token->claims()->all(), 'headers' => $token->headers()->all(), 'signature' => $token->signature()->toString()]
346 $constraints = $configuration->validationConstraints();
348 try {
349 // phpseclib's RSA validation triggers a NOTICE that gets printed to the screen which messes up the JSON result returned
350 // TODO: if phpseclib fixes this error remove the @ ignore sign, note this does not disable the exceptions.
351 @$configuration->validator()->assert($token, ...$constraints);
352 } catch (RequiredConstraintsViolated $exception) {
353 $this->logger->error(
354 "CustomClientCredentialsGrant->validateClient() jwt failed required constraints",
356 'client' => $clientId, 'exceptionMessage' => $exception->getMessage()
357 , 'claims' => $token->claims()->all()
358 ,'expectedAudience' => $this->authTokenUrl
361 // ONC Inferno server refuses to allow a 401 HTTP status code to pass their test suite and requires
362 // a 400 HTTP status code, despite the SMART spec specifically stating that invalid_client w/ 401 is
363 // the response https://hl7.org/fhir/uv/bulkdata/authorization/index.html#signature-verification
364 // so we force this to be a 400 exception
365 // TODO: @adunsulag is there an update to inferno that fixes this issue? (as of inferno 1.9.0 there is no update).
366 $exception = new OAuthServerException('Client authentication failed', 4, 'invalid_client', 400);
367 $exception->setServerRequest($request);
368 throw $exception;
370 } catch (CannotDecodeContent | InvalidTokenStructure | UnsupportedHeaderFound $exception) {
371 $this->logger->error(
372 "CustomClientCredentialsGrant->validateClient() failed to parse token",
373 ['client' => $clientId, 'exceptionMessage' => $exception->getMessage()]
375 throw OAuthServerException::invalidClient($request);
376 } catch (JWKValidatorException | \InvalidArgumentException $exception) {
377 $this->logger->error(
378 "CustomClientCredentialsGrant->validateClient() failed to retrieve jwk for client",
379 ['client' => $clientId, 'exceptionMessage' => $exception->getMessage()]
381 throw OAuthServerException::invalidClient($request);
384 if ($this->clientRepository->validateClient($clientId, null, $this->getIdentifier()) === false) {
385 $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
387 throw OAuthServerException::invalidClient($request);
390 // If a redirect URI is provided ensure it matches what is pre-registered
391 $redirectUri = $this->getRequestParameter('redirect_uri', $request, null);
393 if ($redirectUri !== null) {
394 $this->validateRedirectUri($redirectUri, $client, $request);
397 // if everything is valid we are going to save off the jti so we can prevent replay attacks
398 $this->saveJwtHistory($jwtRepository, $clientId, $token);
400 return $client;
404 * Retrieves the JWT assertion object.
405 * @param ServerRequestInterface $request
406 * @return string|null
408 private function getJWTFromRequest(ServerRequestInterface $request)
410 return $this->getRequestParameter('client_assertion', $request, null);
413 private function saveJwtHistory(JWTRepository $jwtRepository, $clientId, Token $token)
415 $exp = null;
416 $jti = null;
417 try {
418 $exp = $token->claims()->get("exp", null);
419 if ($exp instanceof \DateTimeInterface) {
420 $exp = $exp->getTimestamp();
422 $jti = $token->claims()->get("jti");
423 $jwtRepository->saveJwtHistory($jti, $clientId, $exp);
424 } catch (SqlQueryException $exception) {
425 (new SystemLogger())->error(
426 "Failed to save jti to database Exception: " . $exception->getMessage(),
427 ['clientId' => $clientId, 'exp' => $exp, 'jti' => $jti]
429 throw OAuthServerException::serverError("Server error occurred in parsing JWT", $exception);