Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 164
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
OAuth2Controller
0.00% covered (danger)
0.00%
0 / 164
0.00% covered (danger)
0.00%
0 / 10
1190
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onDispatch
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 authorizeAction
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
182
 tokenAction
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 userInfoAction
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 jwksAction
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 wellKnownConfigurationAction
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 convertOAuthServerExceptionToResponse
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 handleException
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 handleOAuth2Exception
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * OAuth2 Controller
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2022-2024.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.    See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Controller
25 * @author   Ere Maijala <ere.maijala@helsinki.fi>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Site
28 */
29
30namespace VuFind\Controller;
31
32use Laminas\Http\Exception\InvalidArgumentException;
33use Laminas\Http\Response;
34use Laminas\Log\LoggerAwareInterface;
35use Laminas\Mvc\Exception\DomainException;
36use Laminas\Psr7Bridge\Psr7Response;
37use Laminas\Psr7Bridge\Psr7ServerRequest;
38use Laminas\ServiceManager\ServiceLocatorInterface;
39use Laminas\Session\Container as SessionContainer;
40use League\OAuth2\Server\Exception\OAuthServerException;
41use OpenIDConnectServer\ClaimExtractor;
42use VuFind\Config\PathResolver;
43use VuFind\Db\Service\AccessTokenServiceInterface;
44use VuFind\Exception\BadRequest as BadRequestException;
45use VuFind\OAuth2\Entity\UserEntity;
46use VuFind\OAuth2\Repository\IdentityRepository;
47use VuFind\Validator\CsrfInterface;
48
49use function in_array;
50use function is_array;
51
52/**
53 * OAuth2 Controller
54 *
55 * Provides authorization support for external systems
56 *
57 * @category VuFind
58 * @package  Controller
59 * @author   Ere Maijala <ere.maijala@helsinki.fi>
60 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
61 * @link     https://vufind.org Main Site
62 */
63class OAuth2Controller extends AbstractBase implements LoggerAwareInterface
64{
65    use \VuFind\Log\LoggerAwareTrait;
66    use Feature\ResponseFormatterTrait;
67
68    // Session container name
69    public const SESSION_NAME = 'OAuth2Server';
70
71    /**
72     * OAuth2 authorization server factory
73     *
74     * @var callable
75     */
76    protected $oauth2ServerFactory;
77
78    /**
79     * OAuth2 resource server factory
80     *
81     * @var callable
82     */
83    protected $resourceServerFactory;
84
85    /**
86     * Constructor
87     *
88     * @param ServiceLocatorInterface     $sm                 Service locator
89     * @param array                       $oauth2Config       OAuth2 configuration
90     * @param callable                    $asf                OAuth2 authorization server factory
91     * @param callable                    $rsf                OAuth2 resource server factory
92     * @param CsrfInterface               $csrf               CSRF validator
93     * @param SessionContainer            $session            Session container
94     * @param IdentityRepository          $identityRepository Identity repository
95     * @param AccessTokenServiceInterface $accessTokenService Access token service
96     * @param ClaimExtractor              $claimExtractor     Claim extractor
97     * @param PathResolver                $pathResolver       Config file path resolver
98     * path
99     */
100    public function __construct(
101        ServiceLocatorInterface $sm,
102        protected array $oauth2Config,
103        callable $asf,
104        callable $rsf,
105        protected CsrfInterface $csrf,
106        protected \Laminas\Session\Container $session,
107        protected IdentityRepository $identityRepository,
108        protected AccessTokenServiceInterface $accessTokenService,
109        protected ClaimExtractor $claimExtractor,
110        protected PathResolver $pathResolver
111    ) {
112        parent::__construct($sm);
113        $this->oauth2ServerFactory = $asf;
114        $this->resourceServerFactory = $rsf;
115    }
116
117    /**
118     * Execute the request
119     *
120     * @param \Laminas\Mvc\MvcEvent $e Event
121     *
122     * @return mixed
123     * @throws DomainException
124     * @throws InvalidArgumentException
125     */
126    public function onDispatch(\Laminas\Mvc\MvcEvent $e)
127    {
128        // Add CORS headers and handle OPTIONS requests. This is a simplistic
129        // approach since we allow any origin. For more complete CORS handling
130        // a module like zfr-cors could be used.
131        $request = $this->getRequest();
132        if ($request->getMethod() == 'OPTIONS') {
133            // Disable session writes
134            $this->disableSessionWrites();
135            $response = $this->getResponse();
136            $response->setStatusCode(204);
137            $this->addCorsHeaders($response);
138            return $response;
139        }
140        return parent::onDispatch($e);
141    }
142
143    /**
144     * OAuth2 authorization request action
145     *
146     * @return mixed
147     */
148    public function authorizeAction()
149    {
150        // Validate the authorization request:
151        $laminasRequest = $this->getRequest();
152        $clientId = $laminasRequest->getQuery('client_id');
153        if (
154            empty($clientId)
155            || !($clientConfig = $this->oauth2Config['Clients'][$clientId] ?? [])
156        ) {
157            throw new BadRequestException("Invalid OAuth2 client $clientId");
158        }
159
160        if (!($user = $this->getUser())) {
161            return $this->forceLogin('external_auth_access_login_message');
162        }
163
164        $server = ($this->oauth2ServerFactory)($clientId);
165        try {
166            $authRequest = $server->validateAuthorizationRequest(
167                Psr7ServerRequest::fromLaminas($this->getRequest())
168            );
169        } catch (OAuthServerException $e) {
170            return $this->handleOAuth2Exception('Authorization request', $e);
171        } catch (\Exception $e) {
172            return $this->handleException('Authorization request', $e);
173        }
174
175        if ($this->formWasSubmitted('allow') || $this->formWasSubmitted('deny')) {
176            // Check CSRF and session:
177            if (!$this->csrf->isValid($this->getRequest()->getPost()->get('csrf'))) {
178                throw new \VuFind\Exception\BadRequest(
179                    'error_inconsistent_parameters'
180                );
181            }
182
183            // Store OpenID nonce (or null if not present to clear any existing one)
184            // in the access token table so that it can be retrieved for token or
185            // user info action:
186            $this->accessTokenService
187                ->storeNonce($user->getId(), $laminasRequest->getQuery('nonce'));
188
189            $authRequest->setUser(
190                new UserEntity(
191                    $user,
192                    $this->getILS(),
193                    $this->oauth2Config,
194                    $this->accessTokenService,
195                    $this->getILSAuthenticator()
196                )
197            );
198            $authRequest->setAuthorizationApproved($this->formWasSubmitted('allow'));
199
200            try {
201                $response = $server->completeAuthorizationRequest(
202                    $authRequest,
203                    new \Laminas\Diactoros\Response()
204                );
205                return Psr7Response::toLaminas($response);
206            } catch (OAuthServerException $e) {
207                return $this->handleOAuth2Exception('Authorization request', $e);
208            } catch (\Exception $e) {
209                return $this->handleException('Authorization request', $e);
210            }
211        }
212
213        $userIdentifierField = $this->oauth2Config['Server']['userIdentifierField'] ?? 'id';
214        $patron = $this->catalogLogin();
215        $patronLoginView = is_array($patron) ? null : $patron;
216        if ($patronLoginView instanceof \Laminas\View\Model\ViewModel) {
217            $patronLoginView->showMenu = false;
218        }
219        return $this->createViewModel(
220            compact('authRequest', 'user', 'patron', 'patronLoginView', 'userIdentifierField')
221        );
222    }
223
224    /**
225     * OAuth2 token request action
226     *
227     * @return mixed
228     */
229    public function tokenAction()
230    {
231        $this->disableSessionWrites();
232        $server = ($this->oauth2ServerFactory)(null);
233        try {
234            $response = $server->respondToAccessTokenRequest(
235                Psr7ServerRequest::fromLaminas($this->getRequest()),
236                new \Laminas\Diactoros\Response()
237            );
238            $response = Psr7Response::toLaminas($response);
239            $this->addCorsHeaders($response);
240            return $response;
241        } catch (OAuthServerException $e) {
242            return $this->handleOAuth2Exception('Access token request', $e);
243        } catch (\Exception $e) {
244            return $this->handleException('Access token request', $e);
245        }
246    }
247
248    /**
249     * OpenID Connect user info request action
250     *
251     * @return mixed
252     */
253    public function userInfoAction()
254    {
255        $this->disableSessionWrites();
256        try {
257            $laminasRequest = $this->getRequest();
258            $request = ($this->resourceServerFactory)()
259                ->validateAuthenticatedRequest(
260                    Psr7ServerRequest::fromLaminas($laminasRequest)
261                );
262            $scopes = $request->getAttribute('oauth_scopes');
263            if (!in_array('openid', $scopes)) {
264                return $this->handleOAuth2Exception(
265                    'User info request',
266                    OAuthServerException::invalidRequest(
267                        'token',
268                        'Not an OpenID request'
269                    )
270                );
271            }
272            $userId = $request->getAttribute('oauth_user_id');
273            $userEntity = $this->identityRepository
274                ->getUserEntityByIdentifier($userId);
275            if (!$userEntity) {
276                return $this->handleOAuth2Exception(
277                    'User info request',
278                    OAuthServerException::accessDenied('User does not exist anymore')
279                );
280            }
281            $result = $this->claimExtractor
282                ->extract($scopes, $userEntity->getClaims());
283            return $this->getJsonResponse($result);
284        } catch (OAuthServerException $e) {
285            return $this->handleOAuth2Exception('User info request', $e);
286        } catch (\Exception $e) {
287            return $this->handleException('User info request', $e);
288        }
289    }
290
291    /**
292     * Action to retrieve JSON Web Keys
293     *
294     * @see https://www.tuxed.net/fkooman/blog/json_web_key_set.html
295     *
296     * @return mixed
297     */
298    public function jwksAction()
299    {
300        // Check that authorization server can be created (means that config is good):
301        try {
302            ($this->oauth2ServerFactory)(null);
303        } catch (\Exception $e) {
304            return $this->createHttpNotFoundModel($this->getResponse());
305        }
306        $result = [];
307        $keyPath = $this->oauth2Config['Server']['publicKeyPath'] ?? '';
308        if (strncmp($keyPath, '/', 1) !== 0) {
309            $keyPath = $this->pathResolver->getConfigPath($keyPath);
310        }
311        if (file_exists($keyPath)) {
312            $keyDetails = openssl_pkey_get_details(
313                openssl_pkey_get_public(file_get_contents($keyPath))
314            );
315
316            $encodeKeyData = function ($s) {
317                return rtrim(
318                    str_replace(
319                        ['+', '/'],
320                        ['-', '_'],
321                        base64_encode($s)
322                    ),
323                    '='
324                );
325            };
326
327            $result = [
328                'keys' => [
329                    [
330                        'kty' => 'RSA',
331                        'n' => $encodeKeyData($keyDetails['rsa']['n']),
332                        'e' => $encodeKeyData($keyDetails['rsa']['e']),
333                    ],
334                ],
335            ];
336        }
337
338        return $this->getJsonResponse($result);
339    }
340
341    /**
342     * Action to retrieve the OIDC configuration
343     *
344     * @return mixed
345     */
346    public function wellKnownConfigurationAction()
347    {
348        // Check that authorization server can be created (means that config is good):
349        try {
350            ($this->oauth2ServerFactory)(null);
351        } catch (\Exception $e) {
352            return $this->createHttpNotFoundModel($this->getResponse());
353        }
354        $baseUrl = rtrim($this->getServerUrl('home'), '/');
355        $configuration = [
356            'issuer' => 'https://' . $_SERVER['HTTP_HOST'], // Same as OpenIDConnectServer\IdTokenResponse
357            'authorization_endpoint' => "$baseUrl/OAuth2/Authorize",
358            'token_endpoint' => "$baseUrl/OAuth2/Token",
359            'userinfo_endpoint' => "$baseUrl/OAuth2/UserInfo",
360            'jwks_uri' => "$baseUrl/OAuth2/jwks",
361            'response_types_supported' => ['code'],
362            'grant_types_supported' => ['authorization_code'],
363            'subject_types_supported' => ['public'],
364            'id_token_signing_alg_values_supported' => ['RS256'],
365        ];
366        if ($url = $this->oauth2Config['Server']['documentationUrl'] ?? null) {
367            $configuration['service_documentation'] = $url;
368        }
369
370        return $this->getJsonResponse($configuration);
371    }
372
373    /**
374     * Convert an instance of OAuthServerException to a Laminas response.
375     *
376     * @param OAuthServerException $exception Exception
377     *
378     * @return Response
379     */
380    protected function convertOAuthServerExceptionToResponse(
381        OAuthServerException $exception
382    ): Response {
383        $psr7Response = $exception->generateHttpResponse(
384            new \Laminas\Diactoros\Response()
385        );
386        $response = Psr7Response::toLaminas($psr7Response);
387        $this->addCorsHeaders($response);
388        return $response;
389    }
390
391    /**
392     * Create a server error response.
393     *
394     * @param string     $function Function description
395     * @param \Exception $e        Exception
396     *
397     * @return Response
398     */
399    protected function handleException(string $function, \Exception $e): Response
400    {
401        $this->logError("$function failed: " . (string)$e);
402
403        return $this->convertOAuthServerExceptionToResponse(
404            OAuthServerException::serverError('Server side issue')
405        );
406    }
407
408    /**
409     * Create a server error response from a returnable exception.
410     *
411     * @param string     $function Function description
412     * @param \Exception $e        Exception
413     *
414     * @return Response
415     */
416    protected function handleOAuth2Exception(string $function, \Exception $e): Response
417    {
418        $this->debug("$function exception: " . (string)$e);
419
420        return $this->convertOAuthServerExceptionToResponse($e);
421    }
422}