Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 111 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
OAuth2ControllerFactory | |
0.00% |
0 / 111 |
|
0.00% |
0 / 11 |
462 | |
0.00% |
0 / 1 |
__invoke | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
6 | |||
getAuthorizationServerFactory | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 | |||
getResourceServerFactory | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
addGrantTypes | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
createAuthCodeGrant | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
createRefreshTokenGrant | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getResponseType | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getClaimExtractor | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
getOAuth2ServerSetting | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
checkIfUserIdentifierFieldIsValid | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
getKeyFromConfigPath | |
0.00% |
0 / 8 |
|
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 | |
30 | namespace VuFind\Controller; |
31 | |
32 | use Laminas\ServiceManager\Exception\ServiceNotCreatedException; |
33 | use Laminas\ServiceManager\Exception\ServiceNotFoundException; |
34 | use League\OAuth2\Server\AuthorizationServer; |
35 | use League\OAuth2\Server\CryptKey; |
36 | use League\OAuth2\Server\Grant\AuthCodeGrant; |
37 | use League\OAuth2\Server\Grant\RefreshTokenGrant; |
38 | use League\OAuth2\Server\ResourceServer; |
39 | use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; |
40 | use OpenIDConnectServer\ClaimExtractor; |
41 | use OpenIDConnectServer\Entities\ClaimSetEntity; |
42 | use OpenIDConnectServer\IdTokenResponse; |
43 | use Psr\Container\ContainerExceptionInterface as ContainerException; |
44 | use Psr\Container\ContainerInterface; |
45 | use VuFind\Config\PathResolver; |
46 | use VuFind\Db\Service\AccessTokenServiceInterface; |
47 | use VuFind\OAuth2\Repository\AccessTokenRepository; |
48 | use VuFind\OAuth2\Repository\AuthCodeRepository; |
49 | use VuFind\OAuth2\Repository\ClientRepository; |
50 | use VuFind\OAuth2\Repository\IdentityRepository; |
51 | use VuFind\OAuth2\Repository\RefreshTokenRepository; |
52 | use VuFind\OAuth2\Repository\ScopeRepository; |
53 | |
54 | use 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 | */ |
65 | class 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 | } |