diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php index 6c316aca9e3e..8f1dbe2fd99f 100644 --- a/module/VuFind/config/module.config.php +++ b/module/VuFind/config/module.config.php @@ -179,7 +179,6 @@ 'VuFind\Controller\LibGuidesAZController' => 'VuFind\Controller\AbstractBaseFactory', 'VuFind\Controller\LibraryCardsController' => 'VuFind\Controller\AbstractBaseFactory', 'VuFind\Controller\MyResearchController' => 'VuFind\Controller\MyResearchControllerFactory', - 'VuFind\Controller\OAuth2Controller' => 'VuFind\Controller\OAuth2ControllerFactory', 'VuFind\Controller\OverdriveController' => 'VuFind\Controller\AbstractBaseFactory', 'VuFind\Controller\Pazpar2Controller' => 'VuFind\Controller\AbstractBaseFactory', 'VuFind\Controller\PrimoController' => 'VuFind\Controller\AbstractBaseFactory', @@ -227,8 +226,6 @@ 'librarycards' => 'VuFind\Controller\LibraryCardsController', 'MyResearch' => 'VuFind\Controller\MyResearchController', 'myresearch' => 'VuFind\Controller\MyResearchController', - 'OAuth2' => 'VuFind\Controller\OAuth2Controller', - 'oauth2' => 'VuFind\Controller\OAuth2Controller', 'Overdrive' => 'VuFind\Controller\OverdriveController', 'overdrive' => 'VuFind\Controller\OverdriveController', 'Pazpar2' => 'VuFind\Controller\Pazpar2Controller', diff --git a/module/VuFind/src/VuFind/Action/AbstractAction.php b/module/VuFind/src/VuFind/Action/AbstractAction.php index 99675e5210fb..120a0c5a0542 100644 --- a/module/VuFind/src/VuFind/Action/AbstractAction.php +++ b/module/VuFind/src/VuFind/Action/AbstractAction.php @@ -228,6 +228,10 @@ public function __invoke( return $accessDeniedResponse; } + if ($preprocessResponse = $this->preprocessRequest($request, $response)) { + return $preprocessResponse; + } + return $this->action($request, $response); } catch (Throwable $exception) { return $this->handleException($exception); @@ -263,6 +267,25 @@ protected function validateActionConfig( return null; } + /** + * Preprocess a request before the actual action is executed. + * + * This method is executed just before the actual action (i.e. after permission checks etc.). + * It is meant for preprocessing of requests in a shared base class of multiple actions. + * It may return a suitable response or throw an exception if there are issues. + * + * @param ServerRequestInterface $request Request + * @param ResponseInterface $response Response + * + * @return ?ResponseInterface + */ + protected function preprocessRequest( + ServerRequestInterface $request, + ResponseInterface $response + ): ?ResponseInterface { + return null; + } + /** * Perform the action. * diff --git a/module/VuFind/src/VuFind/Action/OAuth2/AbstractOAuth2Action.php b/module/VuFind/src/VuFind/Action/OAuth2/AbstractOAuth2Action.php new file mode 100644 index 000000000000..4f4eac50b758 --- /dev/null +++ b/module/VuFind/src/VuFind/Action/OAuth2/AbstractOAuth2Action.php @@ -0,0 +1,143 @@ +. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ + +namespace VuFind\Action\OAuth2; + +use League\OAuth2\Server\Exception\OAuthServerException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use VuFind\Action\AbstractTemplateRenderingAction; +use VuFind\ActionHelper\ResponseHelper; +use VuFind\OAuth2\OAuth2ServerService; +use VuFind\ServiceManager\Factory\Autowire; + +/** + * Abstract base class for OAuth2 actions. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +abstract class AbstractOAuth2Action extends AbstractTemplateRenderingAction +{ + /** + * Constructor. + * + * @param OAuth2ServerService $oauth2Service OAuth2 server service + */ + #[Autowire] + public function __construct( + protected OAuth2ServerService $oauth2Service, + ) { + parent::__construct(); + } + + /** + * Preprocess a request before the actual action is executed. + * + * This method is executed just before the actual action (i.e. after permission checks etc.). + * It is meant for preprocessing of requests in a shared base class of multiple actions. + * It may return a suitable response or throw an exception if there are issues. + * + * @param ServerRequestInterface $request Request + * @param ResponseInterface $response Response + * + * @return ?ResponseInterface + */ + protected function preprocessRequest( + ServerRequestInterface $request, + ResponseInterface $response + ): ?ResponseInterface { + if ($request->getMethod() === 'OPTIONS') { + // Disable session writes + $this->disableSessionWrites(); + return $this->getHelper(ResponseHelper::class)->addCorsHeaders($response->withStatus(204)); + } + return null; + } + + /** + * Create a server error response from a returnable OAuth2 exception. + * + * @param ResponseInterface $response Response + * @param string $function Function description + * @param OAuthServerException $e Exception + * + * @return ResponseInterface + */ + protected function handleOAuth2ServerException( + ResponseInterface $response, + string $function, + OAuthServerException $e + ): ResponseInterface { + $this->logError("$function failed: " . (string)$e); + + return $this->convertOAuthServerExceptionToResponse($response, $e); + } + + /** + * Create a server error response from a non-OAuth2 exception. + * + * @param ResponseInterface $response Response + * @param string $function Function description + * @param \Exception $e Exception + * + * @return ResponseInterface + */ + protected function handleOAuth2GenericException( + ResponseInterface $response, + string $function, + \Exception $e + ): ResponseInterface { + $this->logError("$function exception: " . (string)$e); + + return $this->convertOAuthServerExceptionToResponse( + $response, + OAuthServerException::serverError('Server side issue') + ); + } + + /** + * Convert an instance of OAuthServerException to a response. + * + * @param ResponseInterface $response Response + * @param OAuthServerException $exception Exception + * + * @return ResponseInterface + */ + protected function convertOAuthServerExceptionToResponse( + ResponseInterface $response, + OAuthServerException $exception + ): ResponseInterface { + $response = $exception->generateHttpResponse($response); + return $this->getHelper(ResponseHelper::class)->addCorsHeaders($response); + } +} diff --git a/module/VuFind/src/VuFind/Action/OAuth2/AuthorizeAction.php b/module/VuFind/src/VuFind/Action/OAuth2/AuthorizeAction.php new file mode 100644 index 000000000000..a6cad480742c --- /dev/null +++ b/module/VuFind/src/VuFind/Action/OAuth2/AuthorizeAction.php @@ -0,0 +1,168 @@ +. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ + +namespace VuFind\Action\OAuth2; + +use Exception; +use League\OAuth2\Server\Exception\OAuthServerException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use VuFind\ActionHelper\FormHelper; +use VuFind\ActionHelper\LoginHelper; +use VuFind\Auth\Manager as AuthManager; +use VuFind\Db\Service\AccessTokenServiceInterface; +use VuFind\Db\Service\PluginManager as DbServicePluginManager; +use VuFind\Exception\BadRequest as BadRequestException; +use VuFind\ILS\Connection; +use VuFind\OAuth2\Entity\ScopeEntity; +use VuFind\OAuth2\OAuth2ServerService; +use VuFind\ServiceManager\Factory\Autowire; +use VuFind\Validator\CsrfInterface; + +use function in_array; + +/** + * OAuth2 authorization action. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +class AuthorizeAction extends AbstractOAuth2Action +{ + /** + * Constructor. + * + * @param OAuth2ServerService $oauth2Service OAuth2 server service + * @param AuthManager $authManager Authentication manager + * @param CsrfInterface $csrf CSRF validator + * @param AccessTokenServiceInterface $accessTokenService Access token database service + * @param Connection $ilsConnection ILS connection + */ + public function __construct( + OAuth2ServerService $oauth2Service, + protected AuthManager $authManager, + protected CsrfInterface $csrf, + #[Autowire(container: DbServicePluginManager::class)] + protected AccessTokenServiceInterface $accessTokenService, + protected Connection $ilsConnection + ) { + parent::__construct($oauth2Service); + } + + /** + * Handle an authorization request. + * + * @param ServerRequestInterface $request Server request + * @param ResponseInterface $response Response + * + * @return ResponseInterface + */ + public function action( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + // Validate the authorization request: + $clientId = $this->getQueryParam('client_id', ''); + if ( + '' === $clientId + || !($clientConfig = $this->oauth2Service->getClientConfig($clientId)) + ) { + throw new BadRequestException("Invalid OAuth2 client $clientId"); + } + + if (!($user = $this->authManager->getUserObject())) { + return $this->getHelper(LoginHelper::class) + ->forceLogin($request, $response, 'external_auth_access_login_message'); + } + + $authServer = $this->oauth2Service->getAuthorizationServer($clientId); + try { + $authRequest = $authServer->validateAuthorizationRequest($request); + } catch (OAuthServerException $e) { + return $this->handleOAuth2ServerException($response, 'Authorization request', $e); + } catch (\Exception $e) { + return $this->handleOAuth2GenericException($response, 'Authorization request', $e); + } + + // Hide any scopes not allowed by a client-specific filter (see also ScopeRepository for the actual filtering): + if ($allowedScopes = $clientConfig['allowedScopes'] ?? null) { + $scopes = $authRequest->getScopes(); + array_map( + function ($scope) use ($allowedScopes): void { + if (!in_array($scope->getIdentifier(), $allowedScopes)) { + if (!($scope instanceof ScopeEntity)) { + throw new Exception('Scope must be an instance of ScopeEntity'); + } + $scope->setHidden(true); + } + }, + $scopes + ); + $authRequest->setScopes($scopes); + } + + $formHelper = $this->getHelper(FormHelper::class); + if ($formHelper->formWasSubmitted($request, ['allow', 'deny'])) { + // Check CSRF and session: + if (!$this->csrf->isValid($this->getPostParam('csrf'))) { + throw new \VuFind\Exception\BadRequest('error_inconsistent_parameters'); + } + + // Store OpenID nonce (or null if not present to clear any existing one) in the access + // token table so that it can be retrieved for token or user info action: + $this->accessTokenService->storeNonce($user->getId(), $this->getQueryParam('nonce')); + + $authRequest->setUser($this->oauth2Service->getOAuth2UserEntity($user)); + $authRequest->setAuthorizationApproved($formHelper->formWasSubmitted($request, 'allow')); + + try { + return $authServer->completeAuthorizationRequest($authRequest, $response); + } catch (OAuthServerException $e) { + return $this->handleOAuth2ServerException($response, 'Authorization request', $e); + } catch (\Exception $e) { + return $this->handleOAuth2GenericException($response, 'Authorization request', $e); + } + } + + $userIdentifierField = $this->oauth2Service->getUserIdentifierField(); + $patron = $this->getHelper(LoginHelper::class)->catalogLogin($request, $response, false); + if ($patron instanceof ResponseInterface) { + return $patron; + } + $showCatalogLoginForm = !$patron; + return $this->renderTemplate( + $request, + $response, + compact('authRequest', 'user', 'patron', 'showCatalogLoginForm', 'userIdentifierField') + ); + } +} diff --git a/module/VuFind/src/VuFind/Action/OAuth2/JwksAction.php b/module/VuFind/src/VuFind/Action/OAuth2/JwksAction.php new file mode 100644 index 000000000000..f47b21570ad7 --- /dev/null +++ b/module/VuFind/src/VuFind/Action/OAuth2/JwksAction.php @@ -0,0 +1,68 @@ +. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ + +namespace VuFind\Action\OAuth2; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use VuFind\ActionHelper\ResponseHelper; + +/** + * OAuth2 JWKS action. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +class JwksAction extends AbstractOAuth2Action +{ + /** + * Handle a token request. + * + * @param ServerRequestInterface $request Server request + * @param ResponseInterface $response Response + * + * @return ResponseInterface + */ + public function action( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + // Check that authorization server can be created (means that config is good): + if (!$this->oauth2Service->configValid()) { + return $this->renderNotFoundPage($request, $response); + } + + $responseHelper = $this->getHelper(ResponseHelper::class); + $response = $responseHelper->getJsonResponse($response, $this->oauth2Service->getJwks()); + return $responseHelper->addCorsHeaders($response); + } +} diff --git a/module/VuFind/src/VuFind/Action/OAuth2/TokenAction.php b/module/VuFind/src/VuFind/Action/OAuth2/TokenAction.php new file mode 100644 index 000000000000..cda0cf5ee807 --- /dev/null +++ b/module/VuFind/src/VuFind/Action/OAuth2/TokenAction.php @@ -0,0 +1,71 @@ +. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ + +namespace VuFind\Action\OAuth2; + +use League\OAuth2\Server\Exception\OAuthServerException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use VuFind\ActionHelper\ResponseHelper; + +/** + * OAuth2 token request action. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +class TokenAction extends AbstractOAuth2Action +{ + /** + * Handle a token request. + * + * @param ServerRequestInterface $request Server request + * @param ResponseInterface $response Response + * + * @return ResponseInterface + */ + public function action( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + $this->disableSessionWrites(); + $server = $this->oauth2Service->getAuthorizationServer(null); + try { + $response = $server->respondToAccessTokenRequest($request, $response); + return $this->getHelper(ResponseHelper::class)->addCorsHeaders($response); + } catch (OAuthServerException $e) { + return $this->handleOAuth2ServerException($response, 'Access token request', $e); + } catch (\Exception $e) { + return $this->handleOAuth2GenericException($response, 'Access token request', $e); + } + } +} diff --git a/module/VuFind/src/VuFind/Action/OAuth2/UserInfoAction.php b/module/VuFind/src/VuFind/Action/OAuth2/UserInfoAction.php new file mode 100644 index 000000000000..c5c316033e5f --- /dev/null +++ b/module/VuFind/src/VuFind/Action/OAuth2/UserInfoAction.php @@ -0,0 +1,74 @@ +. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ + +namespace VuFind\Action\OAuth2; + +use League\OAuth2\Server\Exception\OAuthServerException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use VuFind\ActionHelper\ResponseHelper; + +/** + * OAuth2 user info action. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +class UserInfoAction extends AbstractOAuth2Action +{ + /** + * Handle a user info request. + * + * @param ServerRequestInterface $request Server request + * @param ResponseInterface $response Response + * + * @return ResponseInterface + */ + public function action( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + $this->disableSessionWrites(); + try { + $responseHelper = $this->getHelper(ResponseHelper::class); + $response = $responseHelper->getJsonResponse( + $response, + $this->oauth2Service->getUserInfo($request) + ); + return $responseHelper->addCorsHeaders($response); + } catch (OAuthServerException $e) { + return $this->handleOAuth2ServerException($response, 'User info request', $e); + } catch (\Exception $e) { + return $this->handleOAuth2GenericException($response, 'User info request', $e); + } + } +} diff --git a/module/VuFind/src/VuFind/Action/OAuth2/WellKnownConfigurationAction.php b/module/VuFind/src/VuFind/Action/OAuth2/WellKnownConfigurationAction.php new file mode 100644 index 000000000000..ab59bb82fff3 --- /dev/null +++ b/module/VuFind/src/VuFind/Action/OAuth2/WellKnownConfigurationAction.php @@ -0,0 +1,70 @@ +. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ + +namespace VuFind\Action\OAuth2; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use VuFind\ActionHelper\ResponseHelper; + +/** + * OAuth2 well-known configuration action. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +class WellKnownConfigurationAction extends AbstractOAuth2Action +{ + /** + * Return well-known configuration. + * + * @param ServerRequestInterface $request Server request + * @param ResponseInterface $response Response + * + * @return ResponseInterface + */ + public function action( + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + if (!$this->oauth2Service->configValid()) { + return $this->renderNotFoundPage($request, $response); + } + + $responseHelper = $this->getHelper(ResponseHelper::class); + $response = $responseHelper->getJsonResponse( + $response, + $this->oauth2Service->getWellKnownConfiguration($request) + ); + return $responseHelper->addCorsHeaders($response); + } +} diff --git a/module/VuFind/src/VuFind/Action/PluginManager.php b/module/VuFind/src/VuFind/Action/PluginManager.php index 6851766a4216..0e4bf73f1610 100644 --- a/module/VuFind/src/VuFind/Action/PluginManager.php +++ b/module/VuFind/src/VuFind/Action/PluginManager.php @@ -121,6 +121,9 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager 'oai/authserver' => Oai\AuthServerAction::class, + 'oauth2/userinfo' => OAuth2\UserInfoAction::class, + 'oauth2/wellknownconfiguration' => OAuth2\WellKnownConfigurationAction::class, + 'primorecord/addtag' => Record\AddTagAction::class, 'primorecord/deletetag' => Record\DeleteTagAction::class, 'primorecord/ajaxtab' => Record\AjaxTabAction::class, @@ -234,6 +237,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager 'Authorityrecord' => 'AuthorityRecord', 'Browzine' => 'BrowZine', 'Myresearch' => 'MyResearch', + 'Oauth2' => 'OAuth2', 'Shortlink' => 'ShortLink', ]; diff --git a/module/VuFind/src/VuFind/ActionHelper/ResponseHelper.php b/module/VuFind/src/VuFind/ActionHelper/ResponseHelper.php index 6c36d559edcb..a35e93c199e8 100644 --- a/module/VuFind/src/VuFind/ActionHelper/ResponseHelper.php +++ b/module/VuFind/src/VuFind/ActionHelper/ResponseHelper.php @@ -150,6 +150,46 @@ public function getExceptionResponse(ResponseInterface $response, string $type, ); } + /** + * Add CORS headers to a response. + * + * @param ResponseInterface $response Response + * @param array $allowedMethods Allowed HTTP methods + * @param array $allowedHeaders Allowed HTTP headers + * @param string $allowedOrigin Allowed origin (see + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin for details) + * @param bool $allowCredentials Whether credentials are allowed + * @param int $maxAge Maximum time in seconds the information from a preflight request + * can be cached + * + * @return ResponseInterface + */ + public function addCorsHeaders( + ResponseInterface $response, + array $allowedMethods = ['GET', 'POST', 'OPTIONS'], + array $allowedHeaders = [], + string $allowedOrigin = '*', + bool $allowCredentials = false, + int $maxAge = 86400 + ): ResponseInterface { + $response = $response + ->withHeader('Access-Control-Allow-Methods', implode(', ', $allowedMethods)) + ->withHeader('Access-Control-Allow-Origin', $allowedOrigin) + ->withHeader('Access-Control-Max-Age', $maxAge); + + if ($allowedHeaders) { + $response = $response->withHeader('Access-Control-Allow-Headers', implode(', ', $allowedHeaders)); + } + if ('*' !== $allowedOrigin) { + $response = $response->withHeader('Vary', 'Origin'); + } + if ($allowCredentials) { + // Note: true is the only valid value; false must not be used. + $response = $response->withHeader('Access-Control-Allow-Credentials', 'true'); + } + return $response; + } + /** * Format the content of a response based on the response type. * diff --git a/module/VuFind/src/VuFind/Controller/OAuth2Controller.php b/module/VuFind/src/VuFind/Controller/OAuth2Controller.php deleted file mode 100644 index 19abf2d59747..000000000000 --- a/module/VuFind/src/VuFind/Controller/OAuth2Controller.php +++ /dev/null @@ -1,443 +0,0 @@ -. - * - * @category VuFind - * @package Controller - * @author Ere Maijala - * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License - * @link https://vufind.org Main Site - */ - -namespace VuFind\Controller; - -use Laminas\Http\Exception\InvalidArgumentException; -use Laminas\Http\Response; -use Laminas\Mvc\Exception\DomainException; -use Laminas\Psr7Bridge\Psr7Response; -use Laminas\Psr7Bridge\Psr7ServerRequest; -use Laminas\ServiceManager\ServiceLocatorInterface; -use Laminas\Session\Container as SessionContainer; -use League\OAuth2\Server\Exception\OAuthServerException; -use OpenIDConnectServer\ClaimExtractor; -use Psr\Log\LoggerAwareInterface; -use VuFind\Config\PathResolver; -use VuFind\Db\Service\AccessTokenServiceInterface; -use VuFind\Exception\BadRequest as BadRequestException; -use VuFind\OAuth2\Entity\UserEntity; -use VuFind\OAuth2\Repository\IdentityRepository; -use VuFind\Validator\CsrfInterface; - -use function in_array; - -/** - * OAuth2 Controller. - * - * Provides authorization support for external systems - * - * @category VuFind - * @package Controller - * @author Ere Maijala - * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License - * @link https://vufind.org Main Site - */ -class OAuth2Controller extends AbstractBase implements LoggerAwareInterface -{ - use \VuFind\Log\LoggerAwareTrait; - use Feature\ResponseFormatterTrait; - - // Session container name - public const SESSION_NAME = 'OAuth2Server'; - - /** - * OAuth2 authorization server factory. - * - * @var callable - */ - protected $oauth2ServerFactory; - - /** - * OAuth2 resource server factory. - * - * @var callable - */ - protected $resourceServerFactory; - - /** - * Constructor. - * - * @param ServiceLocatorInterface $sm Service locator - * @param array $oauth2Config OAuth2 configuration - * @param callable $asf OAuth2 authorization server factory - * @param callable $rsf OAuth2 resource server factory - * @param CsrfInterface $csrf CSRF validator - * @param SessionContainer $session Session container - * @param IdentityRepository $identityRepository Identity repository - * @param AccessTokenServiceInterface $accessTokenService Access token service - * @param ClaimExtractor $claimExtractor Claim extractor - * @param PathResolver $pathResolver Config file path resolver - * path - */ - public function __construct( - ServiceLocatorInterface $sm, - protected array $oauth2Config, - callable $asf, - callable $rsf, - protected CsrfInterface $csrf, - protected \Laminas\Session\Container $session, - protected IdentityRepository $identityRepository, - protected AccessTokenServiceInterface $accessTokenService, - protected ClaimExtractor $claimExtractor, - protected PathResolver $pathResolver - ) { - parent::__construct($sm); - $this->oauth2ServerFactory = $asf; - $this->resourceServerFactory = $rsf; - } - - /** - * Execute the request. - * - * @param \Laminas\Mvc\MvcEvent $e Event - * - * @return mixed - * @throws DomainException - * @throws InvalidArgumentException - */ - public function onDispatch(\Laminas\Mvc\MvcEvent $e) - { - // Add CORS headers and handle OPTIONS requests. This is a simplistic - // approach since we allow any origin. For more complete CORS handling - // a module like zfr-cors could be used. - $request = $this->getRequest(); - if ($request->getMethod() == 'OPTIONS') { - // Disable session writes - $this->disableSessionWrites(); - $response = $this->getResponse(); - $response->setStatusCode(204); - $this->addCorsHeaders($response); - return $response; - } - return parent::onDispatch($e); - } - - /** - * OAuth2 authorization request action. - * - * @return mixed - */ - public function authorizeAction() - { - // Validate the authorization request: - $laminasRequest = $this->getRequest(); - $clientId = $laminasRequest->getQuery('client_id'); - if ( - empty($clientId) - || !($clientConfig = $this->oauth2Config['Clients'][$clientId] ?? []) - ) { - throw new BadRequestException("Invalid OAuth2 client $clientId"); - } - - if (!($user = $this->getUser())) { - return $this->forceLogin('external_auth_access_login_message'); - } - - $server = ($this->oauth2ServerFactory)($clientId); - try { - $authRequest = $server->validateAuthorizationRequest( - Psr7ServerRequest::fromLaminas($this->getRequest()) - ); - } catch (OAuthServerException $e) { - return $this->handleOAuth2Exception('Authorization request', $e); - } catch (\Exception $e) { - return $this->handleException('Authorization request', $e); - } - - // Hide any scopes not allowed by a client-specific filter (see also ScopeRepository for the actual filtering): - if ($allowedScopes = $clientConfig['allowedScopes'] ?? null) { - $scopes = $authRequest->getScopes(); - array_map( - function ($scope) use ($allowedScopes): void { - if (!in_array($scope->getIdentifier(), $allowedScopes)) { - $scope->setHidden(true); - } - }, - $scopes - ); - $authRequest->setScopes($scopes); - } - - if ($this->formWasSubmitted('allow') || $this->formWasSubmitted('deny')) { - // Check CSRF and session: - if (!$this->csrf->isValid($this->getRequest()->getPost()->get('csrf'))) { - throw new \VuFind\Exception\BadRequest( - 'error_inconsistent_parameters' - ); - } - - // Store OpenID nonce (or null if not present to clear any existing one) - // in the access token table so that it can be retrieved for token or - // user info action: - $this->accessTokenService - ->storeNonce($user->getId(), $laminasRequest->getQuery('nonce')); - - $authRequest->setUser( - new UserEntity( - $user, - $this->getILS(), - $this->oauth2Config, - $this->accessTokenService, - $this->getILSAuthenticator() - ) - ); - $authRequest->setAuthorizationApproved($this->formWasSubmitted('allow')); - - try { - $response = $server->completeAuthorizationRequest( - $authRequest, - new \Laminas\Diactoros\Response() - ); - return Psr7Response::toLaminas($response); - } catch (OAuthServerException $e) { - return $this->handleOAuth2Exception('Authorization request', $e); - } catch (\Exception $e) { - return $this->handleException('Authorization request', $e); - } - } - - $userIdentifierField = $this->oauth2Config['Server']['userIdentifierField'] ?? 'id'; - $patron = $this->catalogLogin(false); - if ($patron instanceof Response) { - return $patron; - } - $showCatalogLoginForm = !$patron; - return $this->createViewModel( - compact('authRequest', 'user', 'patron', 'showCatalogLoginForm', 'userIdentifierField') - ); - } - - /** - * OAuth2 token request action. - * - * @return mixed - */ - public function tokenAction() - { - $this->disableSessionWrites(); - $server = ($this->oauth2ServerFactory)(null); - try { - $response = $server->respondToAccessTokenRequest( - Psr7ServerRequest::fromLaminas($this->getRequest()), - new \Laminas\Diactoros\Response() - ); - $response = Psr7Response::toLaminas($response); - $this->addCorsHeaders($response); - return $response; - } catch (OAuthServerException $e) { - return $this->handleOAuth2Exception('Access token request', $e); - } catch (\Exception $e) { - return $this->handleException('Access token request', $e); - } - } - - /** - * OpenID Connect user info request action. - * - * @return mixed - */ - public function userInfoAction() - { - $this->disableSessionWrites(); - try { - $laminasRequest = $this->getRequest(); - $request = ($this->resourceServerFactory)() - ->validateAuthenticatedRequest( - Psr7ServerRequest::fromLaminas($laminasRequest) - ); - $scopes = $request->getAttribute('oauth_scopes'); - if (!in_array('openid', $scopes)) { - return $this->handleOAuth2Exception( - 'User info request', - OAuthServerException::invalidRequest( - 'token', - 'Not an OpenID request' - ) - ); - } - $userId = $request->getAttribute('oauth_user_id'); - $userEntity = $this->identityRepository - ->getUserEntityByIdentifier($userId); - if (!$userEntity) { - return $this->handleOAuth2Exception( - 'User info request', - OAuthServerException::accessDenied('User does not exist anymore') - ); - } - $result = $this->claimExtractor->extract($scopes, $userEntity->getClaims()); - // The sub claim must always be returned: - $result['sub'] = $userId; - return $this->getJsonResponse($result); - } catch (OAuthServerException $e) { - return $this->handleOAuth2Exception('User info request', $e); - } catch (\Exception $e) { - return $this->handleException('User info request', $e); - } - } - - /** - * Action to retrieve JSON Web Keys. - * - * @see https://www.tuxed.net/fkooman/blog/json_web_key_set.html - * - * @return mixed - */ - public function jwksAction() - { - // Check that authorization server can be created (means that config is good): - try { - ($this->oauth2ServerFactory)(null); - } catch (\Exception $e) { - return $this->createHttpNotFoundModel($this->getResponse()); - } - $result = []; - $keyPath = $this->oauth2Config['Server']['publicKeyPath'] ?? ''; - if (strncmp($keyPath, '/', 1) !== 0) { - $keyPath = $this->pathResolver->getConfigPath($keyPath); - } - if (file_exists($keyPath)) { - $keyDetails = openssl_pkey_get_details( - openssl_pkey_get_public(file_get_contents($keyPath)) - ); - - $encodeKeyData = function ($s) { - return rtrim( - str_replace( - ['+', '/'], - ['-', '_'], - base64_encode($s) - ), - '=' - ); - }; - - $result = [ - 'keys' => [ - [ - 'kty' => 'RSA', - 'n' => $encodeKeyData($keyDetails['rsa']['n']), - 'e' => $encodeKeyData($keyDetails['rsa']['e']), - ], - ], - ]; - } - - return $this->getJsonResponse($result); - } - - /** - * Action to retrieve the OIDC configuration. - * - * @return mixed - */ - public function wellKnownConfigurationAction() - { - // Check that authorization server can be created (means that config is good): - try { - ($this->oauth2ServerFactory)(null); - } catch (\Exception $e) { - return $this->createHttpNotFoundModel($this->getResponse()); - } - $baseUrl = rtrim($this->getServerUrl('home'), '/'); - $configuration = [ - 'issuer' => 'https://' . $_SERVER['HTTP_HOST'], // Same as OpenIDConnectServer\IdTokenResponse - 'authorization_endpoint' => "$baseUrl/OAuth2/Authorize", - 'token_endpoint' => "$baseUrl/OAuth2/Token", - 'userinfo_endpoint' => "$baseUrl/OAuth2/UserInfo", - 'jwks_uri' => "$baseUrl/OAuth2/jwks", - 'response_types_supported' => ['code'], - 'grant_types_supported' => ['authorization_code'], - 'subject_types_supported' => ['public'], - 'id_token_signing_alg_values_supported' => ['RS256'], - 'token_endpoint_auth_methods_supported' => [ - 'client_secret_post', - 'client_secret_basic', - ], - ]; - if ($url = $this->oauth2Config['Server']['documentationUrl'] ?? null) { - $configuration['service_documentation'] = $url; - } - if ($scopes = $this->oauth2Config['Scopes'] ?? []) { - $configuration['scopes_supported'] = array_keys($scopes); - } - - return $this->getJsonResponse($configuration); - } - - /** - * Convert an instance of OAuthServerException to a Laminas response. - * - * @param OAuthServerException $exception Exception - * - * @return Response - */ - protected function convertOAuthServerExceptionToResponse( - OAuthServerException $exception - ): Response { - $psr7Response = $exception->generateHttpResponse( - new \Laminas\Diactoros\Response() - ); - $response = Psr7Response::toLaminas($psr7Response); - $this->addCorsHeaders($response); - return $response; - } - - /** - * Create a server error response. - * - * @param string $function Function description - * @param \Exception $e Exception - * - * @return Response - */ - protected function handleException(string $function, \Exception $e): Response - { - $this->logError("$function failed: " . (string)$e); - - return $this->convertOAuthServerExceptionToResponse( - OAuthServerException::serverError('Server side issue') - ); - } - - /** - * Create a server error response from a returnable exception. - * - * @param string $function Function description - * @param \Exception $e Exception - * - * @return Response - */ - protected function handleOAuth2Exception(string $function, \Exception $e): Response - { - $this->debug("$function exception: " . (string)$e); - - return $this->convertOAuthServerExceptionToResponse($e); - } -} diff --git a/module/VuFind/src/VuFind/OAuth2/OAuth2ServerService.php b/module/VuFind/src/VuFind/OAuth2/OAuth2ServerService.php new file mode 100644 index 000000000000..eea14118865b --- /dev/null +++ b/module/VuFind/src/VuFind/OAuth2/OAuth2ServerService.php @@ -0,0 +1,276 @@ +. + * + * @category VuFind + * @package OAuth2 + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ + +namespace VuFind\OAuth2; + +use Closure; +use Exception; +use Laminas\Session\Container as SessionContainer; +use League\OAuth2\Server\AuthorizationServer; +use League\OAuth2\Server\Entities\UserEntityInterface as OAuth2UserEntityInterface; +use League\OAuth2\Server\Exception\OAuthServerException; +use OpenIDConnectServer\ClaimExtractor; +use Psr\Http\Message\ServerRequestInterface; +use VuFind\Auth\ILSAuthenticator; +use VuFind\Config\PathResolver; +use VuFind\Db\Entity\UserEntityInterface; +use VuFind\Db\Service\AccessTokenServiceInterface; +use VuFind\ILS\Connection; +use VuFind\OAuth2\Entity\UserEntity; +use VuFind\OAuth2\Repository\IdentityRepository; +use VuFind\Validator\CsrfInterface; + +use function in_array; + +/** + * OAuth2 server service. + * + * @category VuFind + * @package OAuth2 + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +class OAuth2ServerService +{ + /** + * Session container name. + * + * @var string + */ + public const SESSION_NAME = 'OAuth2Server'; + + /** + * Constructor. + * + * @param Closure $authorizationServerFactory OAuth2 authorization server factory + * @param Closure $resourceServerFactory OAuth2 resource server factory + * @param CsrfInterface $csrf CSRF validator + * @param SessionContainer $sessionContainer Session container + * @param IdentityRepository $identityRepository Identity repository + * @param AccessTokenServiceInterface $accessTokenService Access token service + * @param ClaimExtractor $claimExtractor Claim extractor + * @param PathResolver $pathResolver Config file path resolver + * @param ILSAuthenticator $ilsAuthenticator ILS authenticator + * @param Connection $ilsConnection ILS connection + * @param array $oauth2Config OAuth2 configuration + * @param string $baseUrl VuFind base URL + */ + public function __construct( + protected Closure $authorizationServerFactory, + protected Closure $resourceServerFactory, + protected CsrfInterface $csrf, + protected \Laminas\Session\Container $sessionContainer, + protected IdentityRepository $identityRepository, + protected AccessTokenServiceInterface $accessTokenService, + protected ClaimExtractor $claimExtractor, + protected PathResolver $pathResolver, + protected ILSAuthenticator $ilsAuthenticator, + protected Connection $ilsConnection, + protected array $oauth2Config, + protected string $baseUrl + ) { + } + + /** + * Get authorization server for the specified client. + * + * @param ?string $clientId Client ID, or null for generic server without client-specific configuration + * + * @return AuthorizationServer + */ + public function getAuthorizationServer(?string $clientId): AuthorizationServer + { + return ($this->authorizationServerFactory)($clientId); + } + + /** + * Get OAuth2 user entity from database user entity. + * + * @param UserEntityInterface $user Database user entity + * + * @return OAuth2UserEntityInterface + */ + public function getOAuth2UserEntity(UserEntityInterface $user): OAuth2UserEntityInterface + { + return new UserEntity( + $user, + $this->ilsConnection, + $this->oauth2Config, + $this->accessTokenService, + $this->ilsAuthenticator + ); + } + + /** + * Check if the OAuth2 server configuration is valid. + * + * @return bool + */ + public function configValid(): bool + { + try { + // Verify that config is good by creating the authorization service: + ($this->authorizationServerFactory)(null); + return true; + } catch (Exception $e) { + return false; + } + } + + /** + * Get client configuration. + * + * @param string $clientId Client ID + * + * @return array + */ + public function getClientConfig(string $clientId): array + { + return $this->oauth2Config['Clients'][$clientId] ?? []; + } + + /** + * Get the configured user identifier field. + * + * @return string + */ + public function getUserIdentifierField(): string + { + return $this->oauth2Config['Server']['userIdentifierField'] ?? 'id'; + } + + /** + * Get JWKS as an array. + * + * @return array + */ + public function getJwks(): array + { + if (!$this->configValid()) { + return []; + } + $result = []; + $keyPath = $this->oauth2Config['Server']['publicKeyPath'] ?? ''; + if (!str_starts_with($keyPath, '/')) { + $keyPath = $this->pathResolver->getConfigPath($keyPath); + } + if (file_exists($keyPath)) { + $keyDetails = openssl_pkey_get_details(openssl_pkey_get_public(file_get_contents($keyPath))); + + $encodeKeyData = function ($s) { + return rtrim( + str_replace( + ['+', '/'], + ['-', '_'], + base64_encode($s) + ), + '=' + ); + }; + + $result = [ + 'keys' => [ + [ + 'kty' => 'RSA', + 'n' => $encodeKeyData($keyDetails['rsa']['n']), + 'e' => $encodeKeyData($keyDetails['rsa']['e']), + ], + ], + ]; + } + + return $result; + } + + /** + * Get user information. + * + * @param ServerRequestInterface $request User info request + * + * @throws OAuthServerException + * + * @return array + */ + public function getUserInfo(ServerRequestInterface $request): array + { + $request = ($this->resourceServerFactory)()->validateAuthenticatedRequest($request); + $scopes = $request->getAttribute('oauth_scopes'); + if (!in_array('openid', $scopes)) { + throw OAuthServerException::invalidRequest('token', 'Not an OpenID request'); + } + $userId = $request->getAttribute('oauth_user_id'); + $userEntity = $this->identityRepository->getUserEntityByIdentifier($userId); + if (!$userEntity) { + throw OAuthServerException::accessDenied('User does not exist anymore'); + } + $result = $this->claimExtractor->extract($scopes, $userEntity->getClaims()); + // The sub claim must always be returned: + $result['sub'] = $userId; + return $result; + } + + /** + * Get the OpenID Connect well-known configuration information. + * + * @param ServerRequestInterface $request Request + * + * @return array + * + * @see https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest + */ + public function getWellKnownConfiguration(ServerRequestInterface $request): array + { + $baseUrl = rtrim($this->baseUrl, '/'); + // This must be same as OpenIDConnectServer\IdTokenResponse: + $issuer = 'https://' . $request->getServerParams()['HTTP_HOST'] ?? ''; + $configuration = [ + 'issuer' => $issuer, + 'authorization_endpoint' => "$baseUrl/OAuth2/Authorize", + 'token_endpoint' => "$baseUrl/OAuth2/Token", + 'userinfo_endpoint' => "$baseUrl/OAuth2/UserInfo", + 'jwks_uri' => "$baseUrl/OAuth2/jwks", + 'response_types_supported' => ['code'], + 'grant_types_supported' => ['authorization_code'], + 'subject_types_supported' => ['public'], + 'id_token_signing_alg_values_supported' => ['RS256'], + 'token_endpoint_auth_methods_supported' => [ + 'client_secret_post', + 'client_secret_basic', + ], + ]; + if ($url = $this->oauth2Config['Server']['documentationUrl'] ?? null) { + $configuration['service_documentation'] = $url; + } + if ($scopes = $this->oauth2Config['Scopes'] ?? []) { + $configuration['scopes_supported'] = array_keys($scopes); + } + return $configuration; + } +} diff --git a/module/VuFind/src/VuFind/Controller/OAuth2ControllerFactory.php b/module/VuFind/src/VuFind/OAuth2/OAuth2ServerServiceFactory.php similarity index 86% rename from module/VuFind/src/VuFind/Controller/OAuth2ControllerFactory.php rename to module/VuFind/src/VuFind/OAuth2/OAuth2ServerServiceFactory.php index cf81c868ce2e..8b1631495fe8 100644 --- a/module/VuFind/src/VuFind/Controller/OAuth2ControllerFactory.php +++ b/module/VuFind/src/VuFind/OAuth2/OAuth2ServerServiceFactory.php @@ -1,11 +1,11 @@ . * * @category VuFind - * @package Controller + * @package OAuth2 * @author Ere Maijala * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License * @link https://vufind.org Main Site */ -namespace VuFind\Controller; +namespace VuFind\OAuth2; +use Closure; use Laminas\ServiceManager\Exception\ServiceNotCreatedException; use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Laminas\ServiceManager\Factory\FactoryInterface; use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\Grant\AuthCodeGrant; @@ -42,8 +44,12 @@ use OpenIDConnectServer\IdTokenResponse; use Psr\Container\ContainerExceptionInterface as ContainerException; use Psr\Container\ContainerInterface; +use VuFind\Auth\ILSAuthenticator; use VuFind\Config\PathResolver; use VuFind\Db\Service\AccessTokenServiceInterface; +use VuFind\Http\RouteHelper; +use VuFind\Http\ServerUrlHelper; +use VuFind\ILS\Connection; use VuFind\OAuth2\Repository\AccessTokenRepository; use VuFind\OAuth2\Repository\AuthCodeRepository; use VuFind\OAuth2\Repository\ClientRepository; @@ -54,43 +60,43 @@ use function in_array; /** - * OAuth2 controller factory. + * OAuth2 server service factory. * * @category VuFind - * @package Controller + * @package OAuth2 * @author Ere Maijala * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License * @link https://vufind.org Main Site */ -class OAuth2ControllerFactory extends AbstractBaseFactory +class OAuth2ServerServiceFactory implements FactoryInterface { /** * Service manager. * - * @var ContainerInterface + * @var ?ContainerInterface */ - protected $container; + protected ?ContainerInterface $container = null; /** * OAuth2 configuration. * - * @var array + * @var ?array */ - protected $oauth2Config; + protected ?array $oauth2Config = null; /** * Config file path resolver. * - * @var PathResolver + * @var ?PathResolver */ - protected $pathResolver; + protected ?PathResolver $pathResolver = null; /** * Claim extractor. * - * @var ClaimExtractor + * @var ?ClaimExtractor */ - protected $claimExtractor = null; + protected ?ClaimExtractor $claimExtractor = null; /** * Create an object. @@ -126,25 +132,27 @@ public function __invoke( $this->checkIfUserIdentifierFieldIsValid(); $session = new \Laminas\Session\Container( - OAuth2Controller::SESSION_NAME, + OAuth2ServerService::SESSION_NAME, $container->get(\Laminas\Session\SessionManager::class) ); $dbPluginManager = $container->get(\VuFind\Db\Service\PluginManager::class); + $serverUrlHelper = $container->get(ServerUrlHelper::class); + $routeHelper = $container->get(RouteHelper::class); + $baseUrl = $serverUrlHelper->getUrlForPath($routeHelper->getUrlFromRoute('home')); - return $this->applyPermissions( - $container, - new $requestedName( - $container, - $this->oauth2Config, - $this->getAuthorizationServerFactory(), - $this->getResourceServerFactory(), - $container->get(\VuFind\Validator\CsrfInterface::class), - $session, - $container->get(IdentityRepository::class), - $dbPluginManager->get(AccessTokenServiceInterface::class), - $this->getClaimExtractor(), - $this->pathResolver - ) + return new $requestedName( + Closure::fromCallable($this->getAuthorizationServerFactory()), + Closure::fromCallable($this->getResourceServerFactory()), + $container->get(\VuFind\Validator\CsrfInterface::class), + $session, + $container->get(IdentityRepository::class), + $dbPluginManager->get(AccessTokenServiceInterface::class), + $this->getClaimExtractor(), + $this->pathResolver, + $container->get(ILSAuthenticator::class), + $container->get(Connection::class), + $this->oauth2Config, + $baseUrl ); } diff --git a/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/OAuth2Test.php b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/OAuth2Test.php index 1efc67dd09db..a1564ac9f18a 100644 --- a/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/OAuth2Test.php +++ b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/OAuth2Test.php @@ -5,7 +5,7 @@ * * PHP version 8 * - * Copyright (C) The National Library of Finland 2022-2024. + * Copyright (C) The National Library of Finland 2022-2026. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2, @@ -250,6 +250,15 @@ public function testOAuth2Authorization(string $clientId, array $expectedPermiss $this->assertArrayHasKey('state', $queryParams); $this->assertSame($state, $queryParams['state']); + // Test an OPTIONS request: + $tokenEndpointUrl = $this->getVuFindUrl() . '/OAuth2/token'; + $response = $this->getHttpService()->createGuzzleClient($tokenEndpointUrl) + ->request('OPTIONS', $tokenEndpointUrl); + $this->assertSame(204, $response->getStatusCode()); + $this->assertSame('GET, POST, OPTIONS', $response->getHeader('Access-Control-Allow-Methods')[0] ?? null); + $this->assertSame('*', $response->getHeader('Access-Control-Allow-Origin')[0] ?? null); + $this->assertSame('86400', $response->getHeader('Access-Control-Max-Age')[0] ?? null); + // Fetch and check idToken with back-channel requests: $tokenParams = [ 'code' => $queryParams['code'], @@ -259,7 +268,7 @@ public function testOAuth2Authorization(string $clientId, array $expectedPermiss 'client_secret' => 'mysecret', ]; $response = $this->httpPost( - $this->getVuFindUrl() . '/OAuth2/token', + $tokenEndpointUrl, http_build_query($tokenParams), 'application/x-www-form-urlencoded' );