Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 111
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
OAuth2ControllerFactory
0.00% covered (danger)
0.00%
0 / 111
0.00% covered (danger)
0.00%
0 / 11
462
0.00% covered (danger)
0.00%
0 / 1
 __invoke
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
6
 getAuthorizationServerFactory
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getResourceServerFactory
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 addGrantTypes
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 createAuthCodeGrant
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 createRefreshTokenGrant
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getResponseType
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getClaimExtractor
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getOAuth2ServerSetting
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 checkIfUserIdentifierFieldIsValid
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getKeyFromConfigPath
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * OAuth2 controller factory.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2022.
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\ServiceManager\Exception\ServiceNotCreatedException;
33use Laminas\ServiceManager\Exception\ServiceNotFoundException;
34use League\OAuth2\Server\AuthorizationServer;
35use League\OAuth2\Server\CryptKey;
36use League\OAuth2\Server\Grant\AuthCodeGrant;
37use League\OAuth2\Server\Grant\RefreshTokenGrant;
38use League\OAuth2\Server\ResourceServer;
39use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface;
40use OpenIDConnectServer\ClaimExtractor;
41use OpenIDConnectServer\Entities\ClaimSetEntity;
42use OpenIDConnectServer\IdTokenResponse;
43use Psr\Container\ContainerExceptionInterface as ContainerException;
44use Psr\Container\ContainerInterface;
45use VuFind\Config\PathResolver;
46use VuFind\Db\Service\AccessTokenServiceInterface;
47use VuFind\OAuth2\Repository\AccessTokenRepository;
48use VuFind\OAuth2\Repository\AuthCodeRepository;
49use VuFind\OAuth2\Repository\ClientRepository;
50use VuFind\OAuth2\Repository\IdentityRepository;
51use VuFind\OAuth2\Repository\RefreshTokenRepository;
52use VuFind\OAuth2\Repository\ScopeRepository;
53
54use function in_array;
55
56/**
57 * OAuth2 controller factory.
58 *
59 * @category VuFind
60 * @package  Controller
61 * @author   Ere Maijala <ere.maijala@helsinki.fi>
62 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
63 * @link     https://vufind.org Main Site
64 */
65class OAuth2ControllerFactory extends AbstractBaseFactory
66{
67    /**
68     * Service manager
69     *
70     * @var ContainerInterface
71     */
72    protected $container;
73
74    /**
75     * OAuth2 configuration
76     *
77     * @var array
78     */
79    protected $oauth2Config;
80
81    /**
82     * Config file path resolver
83     *
84     * @var PathResolver
85     */
86    protected $pathResolver;
87
88    /**
89     * Claim extractor
90     *
91     * @var ClaimExtractor
92     */
93    protected $claimExtractor = null;
94
95    /**
96     * Create an object
97     *
98     * @param ContainerInterface $container     Service manager
99     * @param string             $requestedName Service being created
100     * @param null|array         $options       Extra options (optional)
101     *
102     * @return object
103     *
104     * @throws ServiceNotFoundException if unable to resolve the service.
105     * @throws ServiceNotCreatedException if an exception is raised when
106     * creating a service.
107     * @throws ContainerException&\Throwable if any other error occurs
108     */
109    public function __invoke(
110        ContainerInterface $container,
111        $requestedName,
112        array $options = null
113    ) {
114        if (!empty($options)) {
115            throw new \Exception('Unexpected options sent to factory.');
116        }
117        $this->container = $container;
118        $this->pathResolver = $container->get(PathResolver::class);
119
120        // Load configuration:
121        $yamlReader = $container->get(\VuFind\Config\YamlReader::class);
122        $this->oauth2Config = $yamlReader->get('OAuth2Server.yaml');
123
124        // Check that user identifier field is valid
125        $this->checkIfUserIdentifierFieldIsValid();
126
127        $session = new \Laminas\Session\Container(
128            OAuth2Controller::SESSION_NAME,
129            $container->get(\Laminas\Session\SessionManager::class)
130        );
131        $dbPluginManager = $container->get(\VuFind\Db\Service\PluginManager::class);
132
133        return $this->applyPermissions(
134            $container,
135            new $requestedName(
136                $container,
137                $this->oauth2Config,
138                $this->getAuthorizationServerFactory(),
139                $this->getResourceServerFactory(),
140                $container->get(\VuFind\Validator\CsrfInterface::class),
141                $session,
142                $container->get(IdentityRepository::class),
143                $dbPluginManager->get(AccessTokenServiceInterface::class),
144                $this->getClaimExtractor(),
145                $this->pathResolver
146            )
147        );
148    }
149
150    /**
151     * Return a factory function for creating the authorization server.
152     *
153     * @return callable
154     */
155    protected function getAuthorizationServerFactory(): callable
156    {
157        return function (?string $clientId): AuthorizationServer {
158            // This could be called with incomplete configuration, so get settings
159            // first:
160            $privateKeyPath = $this->getKeyFromConfigPath('privateKeyPath');
161            $encryptionKey = $this->getOAuth2ServerSetting('encryptionKey');
162            $server = new AuthorizationServer(
163                $this->container->get(ClientRepository::class),
164                $this->container->get(AccessTokenRepository::class),
165                $this->container->get(ScopeRepository::class),
166                $privateKeyPath,
167                $encryptionKey,
168                $this->getResponseType()
169            );
170            $clientConfig = $clientId
171                ? ($this->oauth2Config['Clients'][$clientId] ?? null) : null;
172            $this->addGrantTypes($server, $clientConfig);
173            return $server;
174        };
175    }
176
177    /**
178     * Return a ResourceServer.
179     *
180     * @return callable
181     */
182    protected function getResourceServerFactory(): callable
183    {
184        return function (): ResourceServer {
185            return new ResourceServer(
186                $this->container->get(AccessTokenRepository::class),
187                $this->getKeyFromConfigPath('publicKeyPath')
188            );
189        };
190    }
191
192    /**
193     * Add grant types to the server
194     *
195     * @param AuthorizationServer $server       Authorization server
196     * @param ?array              $clientConfig Client configuration
197     *
198     * @return void
199     */
200    protected function addGrantTypes(
201        AuthorizationServer $server,
202        ?array $clientConfig
203    ): void {
204        $accessTokenLifeTime = new \DateInterval(
205            $this->oauth2Config['Grants']['accessTokenLifeTime'] ?? 'PT1H'
206        );
207
208        // Enable the auth code grant on the server
209        $server->enableGrantType(
210            $this->createAuthCodeGrant($clientConfig),
211            $accessTokenLifeTime
212        );
213
214        // Enable the refresh token grant on the server
215        $server->enableGrantType(
216            $this->createRefreshTokenGrant(),
217            $accessTokenLifeTime
218        );
219    }
220
221    /**
222     * Create an auth code grant
223     *
224     * @param ?array $clientConfig Client configuration
225     *
226     * @return AuthCodeGrant
227     */
228    protected function createAuthCodeGrant(?array $clientConfig): AuthCodeGrant
229    {
230        $config = $this->oauth2Config['Grants'] ?? [];
231        $authCodeLifeTime = $config['authCodeLifeTime'] ?? 'PT1M';
232
233        $grant = new AuthCodeGrant(
234            $this->container->get(AuthCodeRepository::class),
235            $this->container->get(RefreshTokenRepository::class),
236            new \DateInterval($authCodeLifeTime)
237        );
238
239        // Configure for client, if any:
240        if ($clientConfig && empty($clientConfig['pkce'])) {
241            $grant->disableRequireCodeChallengeForPublicClients();
242        }
243
244        return $grant;
245    }
246
247    /**
248     * Create a refresh token grant
249     *
250     * @return RefreshTokenGrant
251     */
252    protected function createRefreshTokenGrant(): RefreshTokenGrant
253    {
254        $config = $this->oauth2Config['Grants'] ?? [];
255        $refreshLifeTime = $config['refreshTokenLifeTime'] ?? 'PT1M';
256
257        $rtGrant = new RefreshTokenGrant(
258            $this->container->get(RefreshTokenRepository::class)
259        );
260        $rtGrant->setRefreshTokenTTL(new \DateInterval($refreshLifeTime));
261        return $rtGrant;
262    }
263
264    /**
265     * Return an OAuth2 response type.
266     *
267     * @return ResponseTypeInterface
268     */
269    protected function getResponseType(): ResponseTypeInterface
270    {
271        return new IdTokenResponse(
272            $this->container->get(IdentityRepository::class),
273            $this->getClaimExtractor()
274        );
275    }
276
277    /**
278     * Get the claim extractor.
279     *
280     * @return ClaimExtractor
281     */
282    protected function getClaimExtractor(): ClaimExtractor
283    {
284        if (null === $this->claimExtractor) {
285            $this->claimExtractor = new ClaimExtractor();
286            foreach ($this->oauth2Config['Scopes'] as $scopeId => $scopeConfig) {
287                if (empty($scopeConfig['claims'])) {
288                    continue;
289                }
290                $this->claimExtractor->addClaimSet(
291                    new ClaimSetEntity($scopeId, $scopeConfig['claims'])
292                );
293            }
294        }
295        return $this->claimExtractor;
296    }
297
298    /**
299     * Return a server setting from the OAuth2 configuration.
300     *
301     * @param string $setting Setting name
302     *
303     * @return string
304     *
305     * @throws \Exception if the setting doesn't exist or is empty.
306     */
307    protected function getOAuth2ServerSetting(string $setting): string
308    {
309        if (!($result = $this->oauth2Config['Server'][$setting] ?? '')) {
310            throw new \Exception(
311                "Server/$setting missing from OAuth2Server.yaml"
312            );
313        }
314        return $result;
315    }
316
317    /**
318     * Check that the user identifier field is valid.
319     *
320     * @return void
321     *
322     * @throws \Exception if the field is invalid
323     */
324    protected function checkIfUserIdentifierFieldIsValid()
325    {
326        $userIdentifierField = $this->oauth2Config['Server']['userIdentifierField'] ?? 'id';
327        if (
328            !in_array(
329                $userIdentifierField,
330                ['id', 'username', 'cat_id']
331            )
332        ) {
333            throw new \Exception(
334                "User identifier field '$userIdentifierField' is invalid."
335            );
336        }
337    }
338
339    /**
340     * Return a key path from the OAuth2 configuration.
341     *
342     * Converts the path to absolute as necessary.
343     *
344     * @param string $key Key path to return
345     *
346     * @return CryptKey
347     *
348     * @throws \Exception if the setting doesn't exist or is empty.
349     */
350    protected function getKeyFromConfigPath(string $key): CryptKey
351    {
352        $keyPath = $this->getOAuth2ServerSetting($key);
353        if (strncmp($keyPath, '/', 1) !== 0) {
354            // Convert relative path:
355            $keyPath = $this->pathResolver->getConfigPath($keyPath);
356        }
357        return new CryptKey(
358            $keyPath,
359            null,
360            $this->oauth2Config['Server']['keyPermissionChecks'] ?? true
361        );
362    }
363}