From cb4d7483b2a058b27bb762ccf1da1ee84c998ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 13 Nov 2024 15:29:21 +0100 Subject: [PATCH 01/17] Add hook for OIDC menu entry in admin area --- hooks/hook_adminmenu.php | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 hooks/hook_adminmenu.php diff --git a/hooks/hook_adminmenu.php b/hooks/hook_adminmenu.php new file mode 100644 index 00000000..6be1afc3 --- /dev/null +++ b/hooks/hook_adminmenu.php @@ -0,0 +1,32 @@ +data[$menuKey]) || !is_array($template->data[$menuKey])) { + return; + } + + $moduleConfig = new ModuleConfig(); + + $oidcMenuEntry = [ + ModuleConfig::MODULE_NAME => [ + 'url' => $moduleConfig->getModuleUrl(RoutesEnum::Configuration->value), + 'name' => Translate::noop('OIDC'), + ], + ]; + + // Put our entry before the "Log out" entry. + array_splice($template->data[$menuKey], -1, 0, $oidcMenuEntry); + + $template->getLocalization()->addModuleDomain(ModuleConfig::MODULE_NAME); +} From c4393ab1c6bd79bec613bc0e78fdb309c18da1c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 13 Nov 2024 15:29:54 +0100 Subject: [PATCH 02/17] Remove obsolete frontpage hook --- hooks/hook_frontpage.php | 61 ---------------------------------------- 1 file changed, 61 deletions(-) delete mode 100644 hooks/hook_frontpage.php diff --git a/hooks/hook_frontpage.php b/hooks/hook_frontpage.php deleted file mode 100644 index 9bedae85..00000000 --- a/hooks/hook_frontpage.php +++ /dev/null @@ -1,61 +0,0 @@ -isUpdated(); - - if (!$isUpdated) { - $links['federation']['oidcregistry'] = [ - 'href' => Module::getModuleURL('oidc/install.php'), - 'text' => [ - 'en' => 'OpenID Connect Installation', - 'es' => 'Instalación de OpenID Connect', - 'it' => 'Installazione di OpenID Connect', - ], - 'shorttext' => [ - 'en' => 'OpenID Connect Installation', - 'es' => 'Instalación de OpenID Connect', - 'it' => 'Installazione di OpenID Connect', - ], - ]; - - return; - } - - $links['federation']['oidcregistry'] = [ - 'href' => Module::getModuleURL('oidc/admin-clients/index.php'), - 'text' => [ - 'en' => 'OpenID Connect Client Registry', - 'es' => 'Registro de clientes OpenID Connect', - 'it' => 'Registro dei clients OpenID Connect', - ], - 'shorttext' => [ - 'en' => 'OpenID Connect Registry', - 'es' => 'Registro OpenID Connect', - 'it' => 'Registro dei clients OpenID Connect', - ], - ]; -} From f94c683ef751317adc37328a67ffa514dab3c76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 13 Nov 2024 16:56:04 +0100 Subject: [PATCH 03/17] WIP move to SSP UI --- hooks/hook_adminmenu.php | 2 +- public/assets/css/src/default.css | 71 +++++++++++++++++++++++ routing/routes/routes.php | 10 ++++ routing/services/services.yml | 3 + src/Admin/Authorization.php | 40 +++++++++++++ src/Admin/Menu.php | 52 +++++++++++++++++ src/Admin/Menu/Item.php | 30 ++++++++++ src/Bridges/SspBridge/Utils.php | 7 +++ src/Codebooks/RoutesEnum.php | 4 ++ src/Controller/AdminController.php | 31 ++++++++++ src/Exceptions/AuthorizationException.php | 11 ++++ src/{ => Exceptions}/OidcException.php | 2 +- src/Factories/CacheFactory.php | 8 +-- src/Utils/ClassInstanceBuilder.php | 4 +- templates/base.twig | 35 +++++++++++ templates/config/overview.twig | 11 ++++ templates/includes/menu.twig | 13 +++++ 17 files changed, 326 insertions(+), 8 deletions(-) create mode 100644 public/assets/css/src/default.css create mode 100644 src/Admin/Authorization.php create mode 100644 src/Admin/Menu.php create mode 100644 src/Admin/Menu/Item.php create mode 100644 src/Controller/AdminController.php create mode 100644 src/Exceptions/AuthorizationException.php rename src/{ => Exceptions}/OidcException.php (63%) create mode 100644 templates/base.twig create mode 100644 templates/config/overview.twig create mode 100644 templates/includes/menu.twig diff --git a/hooks/hook_adminmenu.php b/hooks/hook_adminmenu.php index 6be1afc3..0d5f0636 100644 --- a/hooks/hook_adminmenu.php +++ b/hooks/hook_adminmenu.php @@ -20,7 +20,7 @@ function oidc_hook_adminmenu(Template &$template): void $oidcMenuEntry = [ ModuleConfig::MODULE_NAME => [ - 'url' => $moduleConfig->getModuleUrl(RoutesEnum::Configuration->value), + 'url' => $moduleConfig->getModuleUrl(RoutesEnum::AdminConfigOverview->value), 'name' => Translate::noop('OIDC'), ], ]; diff --git a/public/assets/css/src/default.css b/public/assets/css/src/default.css new file mode 100644 index 00000000..669b5670 --- /dev/null +++ b/public/assets/css/src/default.css @@ -0,0 +1,71 @@ +.wrap { + max-width: 1300px; +} + +h2 { + margin: 0.3em; +} + +h3 { + margin-bottom: 0.5em; + font-size: 1.2em; + font-weight: 600; + color: #1c1c1c; +} + +h4 { + margin: 0.4em 0; + font-size: 1.0em; + font-weight: 600; + color: #1c1c1c; +} + +/* Container to hold menu and content */ +.oidc-container { + display: flex; + max-width: inherit; + margin: 0 auto; +} + +/* Style for the left menu */ +.menu { + min-width: 200px; + /*background-color: #f4f4f4;*/ + /*border-right: solid 1px #bbb;*/ + width: auto; +} + +/* Style for the menu items */ +.menu ul { + list-style-type: none; + padding: 0; +} + +.menu ul li { + padding: 0.25rem; +} + +.menu ul li a { + text-decoration: none; + color: #333; + display: block; + padding: 0.5rem; +} + +.menu ul li a:hover { + background-color: #ddd; + padding: 0.5rem; +} + +.menu ul li a.active { + background-color: #eeeeee; + padding: 0.5rem; +} + +/* Style for the content area */ +.content { + flex-grow: 1; + padding: 20px; + max-width: inherit; + background-color: #fff; +} diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 0e84a055..113f6286 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -8,6 +8,7 @@ use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\Controller\AccessTokenController; +use SimpleSAML\Module\oidc\Controller\AdminController; use SimpleSAML\Module\oidc\Controller\AuthorizationController; use SimpleSAML\Module\oidc\Controller\ConfigurationDiscoveryController; use SimpleSAML\Module\oidc\Controller\EndSessionController; @@ -19,6 +20,15 @@ /** @psalm-suppress InvalidArgument */ return function (RoutingConfigurator $routes): void { + /** + * Admin area routes. + */ + $routes->add(RoutesEnum::AdminConfigOverview->name, RoutesEnum::AdminConfigOverview->value) + ->controller([AdminController::class, 'configOverview']); + + /** + * OpenID Connect Discovery routes. + */ $routes->add(RoutesEnum::Configuration->name, RoutesEnum::Configuration->value) ->controller(ConfigurationDiscoveryController::class); diff --git a/routing/services/services.yml b/routing/services/services.yml index 0b4ea060..f3c093c3 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -29,6 +29,9 @@ services: SimpleSAML\Module\oidc\Factories\: resource: '../../src/Factories/*' + SimpleSAML\Module\oidc\Admin\: + resource: '../../src/Admin/*' + SimpleSAML\Module\oidc\Stores\: resource: '../../src/Stores/*' diff --git a/src/Admin/Authorization.php b/src/Admin/Authorization.php new file mode 100644 index 00000000..dea36248 --- /dev/null +++ b/src/Admin/Authorization.php @@ -0,0 +1,40 @@ +sspBridge->utils()->auth()->requireAdmin(); + } catch (Exception $exception) { + throw new AuthorizationException( + Translate::noop('Unable to initiate SimpleSAMLphp admin authentication.'), + $exception->getCode(), + $exception, + ); + } + } + + if (! $this->sspBridge->utils()->auth()->isAdmin()) { + throw new AuthorizationException(Translate::noop('SimpleSAMLphp admin access required.')); + } + } +} diff --git a/src/Admin/Menu.php b/src/Admin/Menu.php new file mode 100644 index 00000000..0c5e15a6 --- /dev/null +++ b/src/Admin/Menu.php @@ -0,0 +1,52 @@ + + */ + protected array $items = []; + + protected ?string $activeHrefPath = null; + + public function __construct(Item ...$items) + { + array_push($this->items, ...$items); + } + + public function addItem(Item $menuItem, int $offset = null): void + { + $offset ??= count($this->items); + + array_splice($this->items, $offset, 0, [$menuItem]); + } + + public function getItems(): array + { + return $this->items; + } + + public function setActiveHrefPath(?string $value): void + { + $this->activeHrefPath = $value; + } + + public function getActiveHrefPath(): ?string + { + return $this->activeHrefPath; + } + + /** + * Item factory method for easy injection in tests. + */ + public function buildItem(string $hrefPath, string $label, ?string $iconAssetPath = null): Item + { + return new Item($hrefPath, $label, $iconAssetPath); + } +} diff --git a/src/Admin/Menu/Item.php b/src/Admin/Menu/Item.php new file mode 100644 index 00000000..7ce311b6 --- /dev/null +++ b/src/Admin/Menu/Item.php @@ -0,0 +1,30 @@ +hrefPath; + } + + public function getLabel(): string + { + return $this->label; + } + + public function getIconAssetPath(): ?string + { + return $this->iconAssetPath; + } +} diff --git a/src/Bridges/SspBridge/Utils.php b/src/Bridges/SspBridge/Utils.php index 12d0c70f..7b7a3705 100644 --- a/src/Bridges/SspBridge/Utils.php +++ b/src/Bridges/SspBridge/Utils.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Utils\Auth; use SimpleSAML\Utils\Config; use SimpleSAML\Utils\HTTP; use SimpleSAML\Utils\Random; @@ -13,6 +14,7 @@ class Utils protected static ?Config $config = null; protected static ?HTTP $http = null; protected static ?Random $random = null; + protected static ?Auth $auth = null; public function config(): Config { @@ -28,4 +30,9 @@ public function random(): Random { return self::$random ??= new Random(); } + + public function auth(): Auth + { + return self::$auth ??= new Auth(); + } } diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index ff875c89..b5b31e3b 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -6,6 +6,10 @@ enum RoutesEnum: string { + // Admin area + case AdminConfigOverview = 'admin/config-overview'; + + // Protocols case Authorization = 'authorization'; case Configuration = '.well-known/openid-configuration'; case FederationConfiguration = '.well-known/openid-federation'; diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php new file mode 100644 index 00000000..4def7e1f --- /dev/null +++ b/src/Controller/AdminController.php @@ -0,0 +1,31 @@ +authorization->requireSspAdmin(true); + } + + public function configOverview(): Response + { + return $this->templateFactory->render( + 'oidc:config/overview.twig', + [ + 'moduleConfig' => $this->moduleConfig, + ], + ); + } +} diff --git a/src/Exceptions/AuthorizationException.php b/src/Exceptions/AuthorizationException.php new file mode 100644 index 00000000..a5b17c4a --- /dev/null +++ b/src/Exceptions/AuthorizationException.php @@ -0,0 +1,11 @@ + +{% endblock %} + +{% block content %} + +

{{ moduleName }}

+ +
+ + {% if showMenu %} + {% include '@oidc/includes/menu.twig' %} + {% endif %} + +
+

{{ subPageTitle }}

+ +{# TODO mivanci status messages#} + + {% block oidcContent %}{% endblock %} +
+
+ +{% endblock content -%} + +{% block postload %}{% endblock postload %} + +{% block oidcPostload %}{% endblock %} diff --git a/templates/config/overview.twig b/templates/config/overview.twig new file mode 100644 index 00000000..eb462d4e --- /dev/null +++ b/templates/config/overview.twig @@ -0,0 +1,11 @@ +{% set subPageTitle = 'Config Overview'|trans %} + +{% extends "@oidc/base.twig" %} + +{% block oidcContent %} + +

{{ 'OIDC module config overview'|trans }}

+ +

{{ 'Migrations'|trans }}

+ +{% endblock oidcContent -%} diff --git a/templates/includes/menu.twig b/templates/includes/menu.twig new file mode 100644 index 00000000..e397c350 --- /dev/null +++ b/templates/includes/menu.twig @@ -0,0 +1,13 @@ +{% if menu|default %} + +{% endif %} \ No newline at end of file From 5e2abff116117d96dd2ed05a93aa2db793e87998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 14 Nov 2024 13:08:51 +0100 Subject: [PATCH 04/17] WIP move to SSP UI --- hooks/hook_adminmenu.php | 18 +++- public/assets/css/src/default.css | 8 +- src/Bridges/SspBridge/Module.php | 16 ++++ src/Bridges/SspBridge/Module/Admin.php | 15 ++++ src/Controller/AdminController.php | 8 +- src/Controller/Client/CreateController.php | 2 +- src/Controller/Client/DeleteController.php | 2 +- src/Controller/Client/EditController.php | 2 +- src/Controller/Client/IndexController.php | 2 +- src/Controller/Client/ShowController.php | 2 +- src/Controller/EndSessionController.php | 2 +- src/Controller/InstallerController.php | 2 +- src/Factories/TemplateFactory.php | 89 +++++++++++++++++-- src/Services/Container.php | 17 +++- templates/base.twig | 2 + templates/config/overview.twig | 4 +- templates/includes/menu.twig | 8 +- .../Client/CreateControllerTest.php | 2 +- .../Client/DeleteControllerTest.php | 2 +- .../Controller/Client/EditControllerTest.php | 2 +- .../Controller/Client/IndexControllerTest.php | 2 +- .../Controller/Client/ShowControllerTest.php | 2 +- .../Controller/InstallerControllerTest.php | 4 +- 23 files changed, 172 insertions(+), 41 deletions(-) create mode 100644 src/Bridges/SspBridge/Module/Admin.php diff --git a/hooks/hook_adminmenu.php b/hooks/hook_adminmenu.php index 0d5f0636..ee277374 100644 --- a/hooks/hook_adminmenu.php +++ b/hooks/hook_adminmenu.php @@ -25,8 +25,22 @@ function oidc_hook_adminmenu(Template &$template): void ], ]; - // Put our entry before the "Log out" entry. - array_splice($template->data[$menuKey], -1, 0, $oidcMenuEntry); + // Put OIDC entry before the 'Log out' entry, if it exists. + $logoutEntryKey = 'logout'; + $logoutEntryValue = null; + if ( + array_key_exists($logoutEntryKey, $template->data[$menuKey]) && + is_array($template->data[$menuKey][$logoutEntryKey]) + ) { + $logoutEntryValue = $template->data[$menuKey][$logoutEntryKey]; + unset($template->data[$menuKey][$logoutEntryKey]); + } + + $template->data[$menuKey] += $oidcMenuEntry; + + if ($logoutEntryValue !== null) { + $template->data[$menuKey][$logoutEntryKey] = $logoutEntryValue; + } $template->getLocalization()->addModuleDomain(ModuleConfig::MODULE_NAME); } diff --git a/public/assets/css/src/default.css b/public/assets/css/src/default.css index 669b5670..a58b96ff 100644 --- a/public/assets/css/src/default.css +++ b/public/assets/css/src/default.css @@ -1,10 +1,10 @@ .wrap { - max-width: 1300px; + /*max-width: 1300px;*/ } -h2 { - margin: 0.3em; -} +/*h2 {*/ +/* margin: 0.3em;*/ +/*}*/ h3 { margin-bottom: 0.5em; diff --git a/src/Bridges/SspBridge/Module.php b/src/Bridges/SspBridge/Module.php index b423fac0..4aca7c26 100644 --- a/src/Bridges/SspBridge/Module.php +++ b/src/Bridges/SspBridge/Module.php @@ -5,11 +5,27 @@ namespace SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module as SspModule; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Module\Admin; class Module { + protected static ?SspModule\oidc\Bridges\SspBridge\Module\Admin $admin = null; + + public function admin(): Admin + { + return self::$admin ??= new Admin(); + } + public function getModuleUrl(string $resource, array $parameters = []): string { return SspModule::getModuleURL($resource, $parameters); } + + /** + * @throws \Exception + */ + public function isModuleEnabled(string $moduleName): bool + { + return SspModule::isModuleEnabled($moduleName); + } } diff --git a/src/Bridges/SspBridge/Module/Admin.php b/src/Bridges/SspBridge/Module/Admin.php new file mode 100644 index 00000000..139763bc --- /dev/null +++ b/src/Bridges/SspBridge/Module/Admin.php @@ -0,0 +1,15 @@ +templateFactory->render( + return $this->templateFactory->build( 'oidc:config/overview.twig', - [ - 'moduleConfig' => $this->moduleConfig, - ], + ['moduleConfig' => $this->moduleConfig], + RoutesEnum::AdminConfigOverview->value, ); } } diff --git a/src/Controller/Client/CreateController.php b/src/Controller/Client/CreateController.php index 434106a0..f9b0d93d 100644 --- a/src/Controller/Client/CreateController.php +++ b/src/Controller/Client/CreateController.php @@ -135,7 +135,7 @@ public function __invoke(): Template|RedirectResponse return new RedirectResponse((new HTTP())->addURLParameters('show.php', ['client_id' => $client['id']])); } - return $this->templateFactory->render('oidc:clients/new.twig', [ + return $this->templateFactory->build('oidc:clients/new.twig', [ 'form' => $form, 'regexUri' => ClientForm::REGEX_URI, 'regexAllowedOriginUrl' => ClientForm::REGEX_ALLOWED_ORIGIN_URL, diff --git a/src/Controller/Client/DeleteController.php b/src/Controller/Client/DeleteController.php index bf8bb575..91cdb659 100644 --- a/src/Controller/Client/DeleteController.php +++ b/src/Controller/Client/DeleteController.php @@ -71,7 +71,7 @@ public function __invoke(ServerRequest $request): Template|RedirectResponse return new RedirectResponse((new HTTP())->addURLParameters('index.php', [])); } - return $this->templateFactory->render('oidc:clients/delete.twig', [ + return $this->templateFactory->build('oidc:clients/delete.twig', [ 'client' => $client, ]); } diff --git a/src/Controller/Client/EditController.php b/src/Controller/Client/EditController.php index 450e861d..6ee8e177 100644 --- a/src/Controller/Client/EditController.php +++ b/src/Controller/Client/EditController.php @@ -143,7 +143,7 @@ public function __invoke(ServerRequest $request): Template|RedirectResponse ); } - return $this->templateFactory->render('oidc:clients/edit.twig', [ + return $this->templateFactory->build('oidc:clients/edit.twig', [ 'form' => $form, 'regexUri' => ClientForm::REGEX_URI, 'regexAllowedOriginUrl' => ClientForm::REGEX_ALLOWED_ORIGIN_URL, diff --git a/src/Controller/Client/IndexController.php b/src/Controller/Client/IndexController.php index c912ab29..1c0ceea1 100644 --- a/src/Controller/Client/IndexController.php +++ b/src/Controller/Client/IndexController.php @@ -44,7 +44,7 @@ public function __invoke(ServerRequest $request): Template $authedUser = $this->authContextService->isSspAdmin() ? null : $this->authContextService->getAuthUserId(); $pagination = $this->clientRepository->findPaginated($page, $query, $authedUser); - return $this->templateFactory->render('oidc:clients/index.twig', [ + return $this->templateFactory->build('oidc:clients/index.twig', [ 'clients' => $pagination['items'], 'numPages' => $pagination['numPages'], 'currentPage' => $pagination['currentPage'], diff --git a/src/Controller/Client/ShowController.php b/src/Controller/Client/ShowController.php index fc85c2fe..02067363 100644 --- a/src/Controller/Client/ShowController.php +++ b/src/Controller/Client/ShowController.php @@ -49,7 +49,7 @@ public function __invoke(ServerRequest $request): Template $client = $this->getClientFromRequest($request); $allowedOrigins = $this->allowedOriginRepository->get($client->getIdentifier()); - return $this->templateFactory->render('oidc:clients/show.twig', [ + return $this->templateFactory->build('oidc:clients/show.twig', [ 'client' => $client, 'allowedOrigins' => $allowedOrigins, ]); diff --git a/src/Controller/EndSessionController.php b/src/Controller/EndSessionController.php index e19ebff9..6e935c5d 100644 --- a/src/Controller/EndSessionController.php +++ b/src/Controller/EndSessionController.php @@ -197,7 +197,7 @@ protected function resolveResponse(LogoutRequest $logoutRequest, bool $wasLogout return new RedirectResponse($postLogoutRedirectUri); } - return $this->templateFactory->render('oidc:/logout.twig', [ + return $this->templateFactory->build('oidc:/logout.twig', [ 'wasLogoutActionCalled' => $wasLogoutActionCalled, ]); } diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php index e74bb269..7bcc6d27 100644 --- a/src/Controller/InstallerController.php +++ b/src/Controller/InstallerController.php @@ -61,7 +61,7 @@ public function __invoke(ServerRequest $request): Template|RedirectResponse return new RedirectResponse((new HTTP())->addURLParameters('admin-clients/index.php', [])); } - return $this->templateFactory->render('oidc:install.twig', [ + return $this->templateFactory->build('oidc:install.twig', [ 'oauth2_enabled' => $oauth2Enabled, ]); } diff --git a/src/Factories/TemplateFactory.php b/src/Factories/TemplateFactory.php index 0fdc5bc3..972d890a 100644 --- a/src/Factories/TemplateFactory.php +++ b/src/Factories/TemplateFactory.php @@ -17,25 +17,100 @@ namespace SimpleSAML\Module\oidc\Factories; use SimpleSAML\Configuration; +use SimpleSAML\Locale\Translate; +use SimpleSAML\Module\oidc\Admin\Menu; +use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\XHTML\Template; class TemplateFactory { - private readonly Configuration $configuration; + protected bool $showMenu = true; + protected bool $includeDefaultMenuItems = true; - public function __construct(Configuration $configuration) - { - $this->configuration = new Configuration($configuration->toArray(), 'oidc'); + public function __construct( + protected readonly Configuration $sspConfiguration, + protected readonly ModuleConfig $moduleConfig, + protected readonly Menu $oidcMenu, + protected readonly SspBridge $sspBridge, + ) { } /** * @throws \SimpleSAML\Error\ConfigurationError */ - public function render(string $templateName, array $data = []): Template - { - $template = new Template($this->configuration, $templateName); + public function build( + string $templateName, + array $data = [], + string $activeHrefPath = null, + ): Template { + $template = new Template($this->sspConfiguration, $templateName); + + if ($this->includeDefaultMenuItems) { + $this->includeDefaultMenuItems(); + } + + if ($activeHrefPath) { + $this->setActiveHrefPath($activeHrefPath); + } + + $template->data = [ + 'sspConfiguration' => $this->sspConfiguration, + 'moduleConfiguration' => $this->moduleConfig, + 'oidcMenu' => $this->oidcMenu, + 'showMenu' => $this->showMenu, + ]; + + if ($this->sspBridge->module()->isModuleEnabled('admin')) { + $template->addTemplatesFromModule('admin'); + $sspMenu = $this->sspBridge->module()->admin()->buildSspAdminMenu(); + $sspMenu->addOption( + 'logout', + $this->sspBridge->utils()->auth()->getAdminLogoutURL(), + Translate::noop('Log out'), + ); + $template = $sspMenu->insert($template); + $template->data['frontpage_section'] = ModuleConfig::MODULE_NAME; + } + $template->data += $data; return $template; } + + protected function includeDefaultMenuItems(): void + { + $this->oidcMenu->addItem( + $this->oidcMenu->buildItem( + $this->moduleConfig->getModuleUrl(RoutesEnum::AdminConfigOverview->value), + \SimpleSAML\Locale\Translate::noop('Config Overview '), + ), + ); + } + + public function setShowMenu(bool $showMenu): TemplateFactory + { + $this->showMenu = $showMenu; + return $this; + } + + public function setIncludeDefaultMenuItems(bool $includeDefaultMenuItems): TemplateFactory + { + $this->includeDefaultMenuItems = $includeDefaultMenuItems; + return $this; + } + + public function setActiveHrefPath(?string $activeHrefPath): TemplateFactory + { + $this->oidcMenu->setActiveHrefPath( + $activeHrefPath ? $this->moduleConfig->getModuleUrl($activeHrefPath) : null, + ); + return $this; + } + + public function getActiveHrefPath(): ?string + { + return $this->oidcMenu->getActiveHrefPath(); + } } diff --git a/src/Services/Container.php b/src/Services/Container.php index 8d13c20a..89868d11 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -31,6 +31,7 @@ use SimpleSAML\Database; use SimpleSAML\Error\Exception; use SimpleSAML\Metadata\MetaDataStorageHandler; +use SimpleSAML\Module\oidc\Admin\Menu; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Factories\AuthorizationServerFactory; @@ -149,7 +150,18 @@ public function __construct() $sessionMessagesService = new SessionMessagesService($session); $this->services[SessionMessagesService::class] = $sessionMessagesService; - $templateFactory = new TemplateFactory($simpleSAMLConfiguration); + $sspBridge = new SspBridge(); + $this->services[SspBridge::class] = $sspBridge; + + $oidcMenu = new Menu(); + $this->services[Menu::class] = $oidcMenu; + + $templateFactory = new TemplateFactory( + $simpleSAMLConfiguration, + $moduleConfig, + $oidcMenu, + $sspBridge, + ); $this->services[TemplateFactory::class] = $templateFactory; $opMetadataService = new OpMetadataService($moduleConfig); @@ -193,9 +205,6 @@ public function __construct() $requestParamsResolver = new RequestParamsResolver($helpers, $core, $federation); $this->services[RequestParamsResolver::class] = $requestParamsResolver; - $sspBridge = new SspBridge(); - $this->services[SspBridge::class] = $sspBridge; - $clientEntityFactory = new ClientEntityFactory( $sspBridge, $helpers, diff --git a/templates/base.twig b/templates/base.twig index 5e828260..d8c5a836 100644 --- a/templates/base.twig +++ b/templates/base.twig @@ -11,6 +11,8 @@ {% block content %} + {%- include "@admin/includes/menu.twig" %} +

{{ moduleName }}

diff --git a/templates/config/overview.twig b/templates/config/overview.twig index eb462d4e..3f6fff49 100644 --- a/templates/config/overview.twig +++ b/templates/config/overview.twig @@ -4,8 +4,6 @@ {% block oidcContent %} -

{{ 'OIDC module config overview'|trans }}

- -

{{ 'Migrations'|trans }}

+

{{ 'TODO config overview'|trans }}

{% endblock oidcContent -%} diff --git a/templates/includes/menu.twig b/templates/includes/menu.twig index e397c350..014fd0a0 100644 --- a/templates/includes/menu.twig +++ b/templates/includes/menu.twig @@ -1,13 +1,15 @@ -{% if menu|default %} +{% if oidcMenu|default %} + + {% endif %} \ No newline at end of file diff --git a/tests/unit/src/Controller/Client/CreateControllerTest.php b/tests/unit/src/Controller/Client/CreateControllerTest.php index b150144e..32ef2e24 100644 --- a/tests/unit/src/Controller/Client/CreateControllerTest.php +++ b/tests/unit/src/Controller/Client/CreateControllerTest.php @@ -99,7 +99,7 @@ public function testCanShowNewClientForm(): void $this->templateFactoryMock ->expects($this->once()) - ->method('render') + ->method('build') ->with('oidc:clients/new.twig', [ 'form' => $this->clientFormMock, 'regexUri' => ClientForm::REGEX_URI, diff --git a/tests/unit/src/Controller/Client/DeleteControllerTest.php b/tests/unit/src/Controller/Client/DeleteControllerTest.php index 07bc0dda..ebcbe55c 100644 --- a/tests/unit/src/Controller/Client/DeleteControllerTest.php +++ b/tests/unit/src/Controller/Client/DeleteControllerTest.php @@ -81,7 +81,7 @@ public function testItAsksConfirmationBeforeDeletingClient(): void $this->serverRequestMock->expects($this->once())->method('getMethod')->willReturn('get'); $this->clientRepositoryMock->expects($this->once())->method('findById')->with('clientid') ->willReturn($this->clientEntityMock); - $this->templateFactoryMock->expects($this->once())->method('render') + $this->templateFactoryMock->expects($this->once())->method('build') ->with('oidc:clients/delete.twig', ['client' => $this->clientEntityMock]) ->willReturn($this->templateStub); diff --git a/tests/unit/src/Controller/Client/EditControllerTest.php b/tests/unit/src/Controller/Client/EditControllerTest.php index f8fd47e3..7e22336c 100644 --- a/tests/unit/src/Controller/Client/EditControllerTest.php +++ b/tests/unit/src/Controller/Client/EditControllerTest.php @@ -160,7 +160,7 @@ public function testItShowsEditClientForm(): void $this->clientFormMock->expects($this->once())->method('setDefaults')->with($data); $this->clientFormMock->expects($this->once())->method('isSuccess')->willReturn(false); $this->formFactoryMock->expects($this->once())->method('build')->willReturn($this->clientFormMock); - $this->templateFactoryMock->expects($this->once())->method('render')->with( + $this->templateFactoryMock->expects($this->once())->method('build')->with( 'oidc:clients/edit.twig', [ 'form' => $this->clientFormMock, diff --git a/tests/unit/src/Controller/Client/IndexControllerTest.php b/tests/unit/src/Controller/Client/IndexControllerTest.php index cfa54219..476c2b98 100644 --- a/tests/unit/src/Controller/Client/IndexControllerTest.php +++ b/tests/unit/src/Controller/Client/IndexControllerTest.php @@ -75,7 +75,7 @@ public function testItShowsClientIndex(): void ], ); - $this->templateFactoryMock->expects($this->once())->method('render')->with( + $this->templateFactoryMock->expects($this->once())->method('build')->with( 'oidc:clients/index.twig', [ 'clients' => [], diff --git a/tests/unit/src/Controller/Client/ShowControllerTest.php b/tests/unit/src/Controller/Client/ShowControllerTest.php index 1487059d..7636e050 100644 --- a/tests/unit/src/Controller/Client/ShowControllerTest.php +++ b/tests/unit/src/Controller/Client/ShowControllerTest.php @@ -90,7 +90,7 @@ public function testItShowsClientDescription(): void ->willReturn([]); $this->templateFactoryMock ->expects($this->once()) - ->method('render') + ->method('build') ->with( 'oidc:clients/show.twig', [ diff --git a/tests/unit/src/Controller/InstallerControllerTest.php b/tests/unit/src/Controller/InstallerControllerTest.php index 097d65d5..745766e3 100644 --- a/tests/unit/src/Controller/InstallerControllerTest.php +++ b/tests/unit/src/Controller/InstallerControllerTest.php @@ -84,7 +84,7 @@ public function testItShowsInformationPage(): void $this->serverRequestMock->expects($this->once())->method('getMethod')->willReturn('GET'); $this->templateFactoryMock ->expects($this->once()) - ->method('render') + ->method('build') ->with('oidc:install.twig', ['oauth2_enabled' => false,]) ->willReturn($this->templateMock); @@ -104,7 +104,7 @@ public function testItRequiresConfirmationBeforeInstallSchema(): void $this->databaseMigrationMock->expects($this->never())->method('migrate'); $this->templateFactoryMock ->expects($this->once()) - ->method('render') + ->method('build') ->with('oidc:install.twig', ['oauth2_enabled' => false,]) ->willReturn($this->templateMock); From e881224b1f6c17ba532e9bce48b4607d35a8139c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 14 Nov 2024 16:50:59 +0100 Subject: [PATCH 05/17] WIP move to SSP UI --- bin/install.php | 2 +- hooks/hook_federationpage.php | 2 +- public/assets/css/src/default.css | 18 ++- routing/routes/routes.php | 3 + src/Codebooks/RoutesEnum.php | 2 + src/Controller/AdminController.php | 26 +++- src/Controller/InstallerController.php | 2 +- src/Factories/AuthSimpleFactory.php | 12 +- src/Factories/TemplateFactory.php | 10 ++ src/ModuleConfig.php | 8 ++ src/Services/Container.php | 1 + src/Services/DatabaseMigration.php | 11 +- templates/base.twig | 8 +- templates/config/overview.twig | 118 +++++++++++++++++- .../Controller/InstallerControllerTest.php | 2 +- 15 files changed, 203 insertions(+), 22 deletions(-) diff --git a/bin/install.php b/bin/install.php index 6028c7ec..acbc3e65 100755 --- a/bin/install.php +++ b/bin/install.php @@ -22,7 +22,7 @@ $database = Database::getInstance(); $databaseMigration = new DatabaseMigration($database); - if ($databaseMigration->isUpdated()) { + if ($databaseMigration->isMigrated()) { echo 'Database is up to date, skipping.' . PHP_EOL; return 0; } diff --git a/hooks/hook_federationpage.php b/hooks/hook_federationpage.php index 6c0ae6d0..0a79066b 100644 --- a/hooks/hook_federationpage.php +++ b/hooks/hook_federationpage.php @@ -27,7 +27,7 @@ function oidc_hook_federationpage(Template $template): void $href = Module::getModuleURL('oidc/admin-clients/index.php'); $text = Translate::noop('OpenID Connect Registry'); - if (! (new DatabaseMigration())->isUpdated()) { + if (! (new DatabaseMigration())->isMigrated()) { $href = Module::getModuleURL('oidc/install.php'); $text = Translate::noop('OpenID Connect Installation'); } diff --git a/public/assets/css/src/default.css b/public/assets/css/src/default.css index a58b96ff..60e5b651 100644 --- a/public/assets/css/src/default.css +++ b/public/assets/css/src/default.css @@ -65,7 +65,23 @@ h4 { /* Style for the content area */ .content { flex-grow: 1; - padding: 20px; + padding-left: 20px; max-width: inherit; background-color: #fff; } + +ul.config { + list-style: disc outside none; +} + +/* Text colors */ +.black-text { color: black; } +.red-text { color: red; } +.lightcoral-text { color: lightcoral; } +.green-text { color: green; } +.yellow-text { color: yellow; } +.blue-text { color: blue; } +.magenta-text { color: magenta; } +.cyan-text { color: cyan; } +.lightcyan-text { color: lightcyan; } +.white-text { color: white; } diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 113f6286..d6deb499 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -25,6 +25,9 @@ */ $routes->add(RoutesEnum::AdminConfigOverview->name, RoutesEnum::AdminConfigOverview->value) ->controller([AdminController::class, 'configOverview']); + $routes->add(RoutesEnum::AdminRunMigrations->name, RoutesEnum::AdminRunMigrations->value) + ->controller([AdminController::class, 'runMigrations']) + ->methods([HttpMethodsEnum::POST->value]); /** * OpenID Connect Discovery routes. diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index b5b31e3b..6128f73a 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -8,6 +8,8 @@ enum RoutesEnum: string { // Admin area case AdminConfigOverview = 'admin/config-overview'; + case AdminRunMigrations = 'admin/run-migrations'; + case AdminClients = 'admin/clients'; // Protocols case Authorization = 'authorization'; diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 8d332b3c..3b655676 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -4,10 +4,14 @@ namespace SimpleSAML\Module\oidc\Controller; +use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Admin\Authorization; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Services\DatabaseMigration; +use SimpleSAML\Module\oidc\Services\SessionMessagesService; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Response; class AdminController @@ -16,6 +20,8 @@ public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly TemplateFactory $templateFactory, protected readonly Authorization $authorization, + protected readonly DatabaseMigration $databaseMigration, + protected readonly SessionMessagesService $sessionMessagesService, ) { $this->authorization->requireSspAdmin(true); } @@ -24,8 +30,26 @@ public function configOverview(): Response { return $this->templateFactory->build( 'oidc:config/overview.twig', - ['moduleConfig' => $this->moduleConfig], + [ + 'moduleConfig' => $this->moduleConfig, + 'databaseMigration' => $this->databaseMigration, + ], RoutesEnum::AdminConfigOverview->value, ); } + + public function runMigrations(): Response + { + if ($this->databaseMigration->isMigrated()) { + $message = Translate::noop('Database is already migrated.'); + $this->sessionMessagesService->addMessage($message); + return new RedirectResponse($this->moduleConfig->getModuleUrl(RoutesEnum::AdminConfigOverview->value)); + } + + $this->databaseMigration->migrate(); + $message = Translate::noop('Database migrated successfully.'); + $this->sessionMessagesService->addMessage($message); + + return new RedirectResponse($this->moduleConfig->getModuleUrl(RoutesEnum::AdminConfigOverview->value)); + } } diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php index 7bcc6d27..61899a94 100644 --- a/src/Controller/InstallerController.php +++ b/src/Controller/InstallerController.php @@ -42,7 +42,7 @@ public function __construct( */ public function __invoke(ServerRequest $request): Template|RedirectResponse { - if ($this->databaseMigration->isUpdated()) { + if ($this->databaseMigration->isMigrated()) { return new RedirectResponse((new HTTP())->addURLParameters('admin-clients/index.php', [])); } diff --git a/src/Factories/AuthSimpleFactory.php b/src/Factories/AuthSimpleFactory.php index 643d9eb8..0f708ce2 100644 --- a/src/Factories/AuthSimpleFactory.php +++ b/src/Factories/AuthSimpleFactory.php @@ -44,7 +44,7 @@ public function build(ClientEntityInterface $clientEntity): Simple */ public function getDefaultAuthSource(): Simple { - return new Simple($this->getDefaultAuthSourceId()); + return new Simple($this->moduleConfig->getDefaultAuthSourceId()); } /** @@ -54,14 +54,6 @@ public function getDefaultAuthSource(): Simple */ public function resolveAuthSourceId(ClientEntityInterface $client): string { - return $client->getAuthSourceId() ?? $this->getDefaultAuthSourceId(); - } - - /** - * @throws \Exception - */ - public function getDefaultAuthSourceId(): string - { - return $this->moduleConfig->config()->getString(ModuleConfig::OPTION_AUTH_SOURCE); + return $client->getAuthSourceId() ?? $this->moduleConfig->getDefaultAuthSourceId(); } } diff --git a/src/Factories/TemplateFactory.php b/src/Factories/TemplateFactory.php index 972d890a..24ffdfeb 100644 --- a/src/Factories/TemplateFactory.php +++ b/src/Factories/TemplateFactory.php @@ -22,6 +22,7 @@ use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Services\SessionMessagesService; use SimpleSAML\XHTML\Template; class TemplateFactory @@ -34,6 +35,7 @@ public function __construct( protected readonly ModuleConfig $moduleConfig, protected readonly Menu $oidcMenu, protected readonly SspBridge $sspBridge, + protected readonly SessionMessagesService $sessionMessagesService, ) { } @@ -60,6 +62,7 @@ public function build( 'moduleConfiguration' => $this->moduleConfig, 'oidcMenu' => $this->oidcMenu, 'showMenu' => $this->showMenu, + 'sessionMessages' => $this->sessionMessagesService->getMessages(), ]; if ($this->sspBridge->module()->isModuleEnabled('admin')) { @@ -87,6 +90,13 @@ protected function includeDefaultMenuItems(): void \SimpleSAML\Locale\Translate::noop('Config Overview '), ), ); + + $this->oidcMenu->addItem( + $this->oidcMenu->buildItem( + $this->moduleConfig->getModuleUrl(RoutesEnum::AdminClients->value), + \SimpleSAML\Locale\Translate::noop('Clients '), + ), + ); } public function setShowMenu(bool $showMenu): TemplateFactory diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index f753bbc7..daaefb5d 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -161,6 +161,14 @@ public function getIssuer(): string return $issuer; } + /** + * @throws \Exception + */ + public function getDefaultAuthSourceId(): string + { + return $this->config()->getString(self::OPTION_AUTH_SOURCE); + } + public function getModuleUrl(string $path = null): string { $base = $this->sspBridge->module()->getModuleURL(self::MODULE_NAME); diff --git a/src/Services/Container.php b/src/Services/Container.php index 89868d11..639f3f0a 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -161,6 +161,7 @@ public function __construct() $moduleConfig, $oidcMenu, $sspBridge, + $sessionMessagesService, ); $this->services[TemplateFactory::class] = $templateFactory; diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index b5b609c0..8f4f3a74 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -35,18 +35,21 @@ public function __construct(Database $database = null) $this->database = $database ?? Database::getInstance(); } - public function isUpdated(): bool + public function isMigrated(): bool + { + return empty($this->getNotImplementedVersions()); + } + + public function getNotImplementedVersions(): array { $implementedVersions = $this->versions(); - $notImplementedVersions = array_filter(get_class_methods($this), function ($method) use ($implementedVersions) { + return array_filter(get_class_methods($this), function ($method) use ($implementedVersions) { if (preg_match('/^version(\d+)/', $method, $matches)) { return !in_array($matches[1], $implementedVersions, true); } return false; }); - - return empty($notImplementedVersions); } public function versions(): array diff --git a/templates/base.twig b/templates/base.twig index d8c5a836..4df64ea1 100644 --- a/templates/base.twig +++ b/templates/base.twig @@ -24,7 +24,13 @@

{{ subPageTitle }}

-{# TODO mivanci status messages#} + {% if sessionMessages is defined and sessionMessages is not empty %} +
+ {% for message in sessionMessages %} + {{ message|trans }}
+ {% endfor %} +
+ {% endif %} {% block oidcContent %}{% endblock %}
diff --git a/templates/config/overview.twig b/templates/config/overview.twig index 3f6fff49..ee695106 100644 --- a/templates/config/overview.twig +++ b/templates/config/overview.twig @@ -4,6 +4,122 @@ {% block oidcContent %} -

{{ 'TODO config overview'|trans }}

+

{{ 'Database Migrations'|trans }}

+ + {% if databaseMigration.isMigrated %} +

{{ 'All database migrations are implemented.'|trans }}

+ {% else %} +

+ + {% trans %}There are database migrations that have not been implemented. + Use the button below to run them now.{% endtrans %} +

+ +
+ + + +
+
+ {% endif %} + +

{{ 'Protocol Settings'|trans }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'Setting'|trans }}{{ 'Value'|trans }}
{{ 'Issuer'|trans }} {{ moduleConfig.getIssuer }}
{{ 'Tokens Time-To-Live'|trans }} +
    +
  • + {{ 'Authorization Code:'|trans }} + {{ moduleConfig.getAuthCodeDuration|date("%mm %dd %hh %i' %s''") }} +
  • +
  • + {{ 'Access Token:'|trans }} + {{ moduleConfig.getAccessTokenDuration|date("%mm %dd %hh %i' %s''") }} +
  • +
  • + {{ 'Refresh Token:'|trans }} + {{ moduleConfig.getRefreshTokenDuration|date("%mm %dd %hh %i' %s''") }} +
  • +
+ +
{{ 'Default Authentication Source'|trans }} {{ moduleConfig.getDefaultAuthSourceId }}
{{ 'User Identifier Attribute'|trans }} {{ moduleConfig.getUserIdentifierAttribute }}
{{ 'PKI'|trans }} +
    +
  • Private Key: {{ moduleConfig.getProtocolPrivateKeyPath }}
  • +
  • + Private Key Password Set: + {{ moduleConfig.getProtocolPrivateKeyPassPhrase ? 'Yes'|trans : 'No'|trans }} +
  • +
  • + {{ 'Public Key:'|trans }} + {{ moduleConfig.getProtocolCertPath }} +
  • +
+ +
{{ 'Signing Algorithm'|trans }} {{ moduleConfig.getProtocolSigner.algorithmId }}
{{ 'Supported ACRs'|trans }} + {% if moduleConfig.getAcrValuesSupported is not empty %} +
    + {% for acr in moduleConfig.getAcrValuesSupported %} +
  • {{ acr }}
  • + {% endfor %} +
+ {% else %} + {{ 'None defined'|trans }} + {% endif %} +
+ +
+

{{ 'Federation Settings'|trans }}

+ + + + + + + + + + + + + + + +
{{ 'Setting'|trans }}{{ 'Value'|trans }}
{{ 'Federation Enabled:'|trans }} {{ moduleConfig.getFederationEnabled ? 'Yes'|trans : 'No'|trans }}
{% endblock oidcContent -%} diff --git a/tests/unit/src/Controller/InstallerControllerTest.php b/tests/unit/src/Controller/InstallerControllerTest.php index 745766e3..b9caa531 100644 --- a/tests/unit/src/Controller/InstallerControllerTest.php +++ b/tests/unit/src/Controller/InstallerControllerTest.php @@ -66,7 +66,7 @@ public function testItReturnsToMainPageIfAlreadyUpdated(): void { $this->databaseMigrationMock ->expects($this->once()) - ->method('isUpdated') + ->method('isMigrated') ->willReturn(true); $this->assertInstanceOf( From 14e91bc4161f48ce072a02ae971d902dfe043205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 15 Nov 2024 18:02:20 +0100 Subject: [PATCH 06/17] WIP move to SSP UI --- config-templates/module_oidc.php | 20 +- hooks/hook_adminmenu.php | 2 +- public/admin-clients/delete.php | 2 +- public/admin-clients/edit.php | 2 +- public/admin-clients/index.php | 2 +- public/admin-clients/new.php | 2 +- public/admin-clients/reset.php | 2 +- public/admin-clients/show.php | 2 +- public/authorize.php | 2 +- public/clients/delete.php | 2 +- public/clients/edit.php | 2 +- public/clients/index.php | 2 +- public/clients/new.php | 2 +- public/clients/reset.php | 2 +- public/clients/show.php | 2 +- public/install.php | 2 +- public/jwks.php | 2 +- public/logout.php | 2 +- public/openid-configuration.php | 2 +- public/token.php | 2 +- public/userinfo.php | 2 +- routing/routes/routes.php | 62 ++-- routing/services/services.yml | 8 + src/Codebooks/RoutesEnum.php | 32 +- .../AccessTokenController.php | 4 +- src/Controllers/Admin/ClientController.php | 30 ++ .../Admin/ConfigController.php} | 37 +- .../AuthorizationController.php | 2 +- .../Client/CreateController.php | 2 +- .../Client/DeleteController.php | 4 +- .../Client/EditController.php | 4 +- .../Client/IndexController.php | 2 +- .../Client/ResetSecretController.php | 4 +- .../Client/ShowController.php | 4 +- .../ConfigurationDiscoveryController.php | 2 +- .../EndSessionController.php | 2 +- .../Federation/EntityStatementController.php | 2 +- .../Federation/Test.php | 2 +- .../InstallerController.php | 2 +- .../JwksController.php | 2 +- ...AuthenticatedGetClientFromRequestTrait.php | 2 +- .../Traits/RequestTrait.php | 2 +- .../UserInfoController.php | 4 +- .../ClaimTranslatorExtractorFactory.php | 2 +- src/Factories/TemplateFactory.php | 23 +- src/Forms/ClientForm.php | 2 +- src/ModuleConfig.php | 338 +++++++++--------- src/Repositories/ScopeRepository.php | 2 +- src/Services/AuthenticationService.php | 2 +- src/Services/Container.php | 8 + src/Services/OpMetadataService.php | 2 +- src/Utils/Routes.php | 105 ++++++ templates/clients.twig | 9 + templates/config/federation.twig | 89 +++++ templates/config/migrations.twig | 30 ++ .../config/{overview.twig => protocol.twig} | 82 ++--- .../Controller/AccessTokenControllerTest.php | 6 +- .../AuthorizationControllerTest.php | 4 +- .../Client/CreateControllerTest.php | 4 +- .../Client/DeleteControllerTest.php | 4 +- .../Controller/Client/EditControllerTest.php | 4 +- .../Controller/Client/IndexControllerTest.php | 4 +- .../Client/ResetSecretControllerTest.php | 4 +- .../Controller/Client/ShowControllerTest.php | 4 +- .../ConfigurationDiscoveryControllerTest.php | 4 +- .../Controller/EndSessionControllerTest.php | 4 +- .../EntityStatementControllerTest.php | 2 +- .../Controller/InstallerControllerTest.php | 4 +- .../src/Controller/JwksControllerTest.php | 4 +- .../Controller/Traits/RequestTraitTest.php | 4 +- .../src/Controller/UserInfoControllerTest.php | 6 +- .../ClaimTranslatorExtractorFactoryTest.php | 2 +- tests/unit/src/ModuleConfigTest.php | 2 +- .../src/Services/OpMetadataServiceTest.php | 2 +- 74 files changed, 696 insertions(+), 341 deletions(-) rename src/{Controller => Controllers}/AccessTokenController.php (95%) create mode 100644 src/Controllers/Admin/ClientController.php rename src/{Controller/AdminController.php => Controllers/Admin/ConfigController.php} (66%) rename src/{Controller => Controllers}/AuthorizationController.php (99%) rename src/{Controller => Controllers}/Client/CreateController.php (99%) rename src/{Controller => Controllers}/Client/DeleteController.php (95%) rename src/{Controller => Controllers}/Client/EditController.php (97%) rename src/{Controller => Controllers}/Client/IndexController.php (97%) rename src/{Controller => Controllers}/Client/ResetSecretController.php (94%) rename src/{Controller => Controllers}/Client/ShowController.php (92%) rename src/{Controller => Controllers}/ConfigurationDiscoveryController.php (94%) rename src/{Controller => Controllers}/EndSessionController.php (99%) rename src/{Controller => Controllers}/Federation/EntityStatementController.php (99%) rename src/{Controller => Controllers}/Federation/Test.php (99%) rename src/{Controller => Controllers}/InstallerController.php (98%) rename src/{Controller => Controllers}/JwksController.php (96%) rename src/{Controller => Controllers}/Traits/AuthenticatedGetClientFromRequestTrait.php (97%) rename src/{Controller => Controllers}/Traits/RequestTrait.php (97%) rename src/{Controller => Controllers}/UserInfoController.php (97%) create mode 100644 src/Utils/Routes.php create mode 100644 templates/clients.twig create mode 100644 templates/config/federation.twig create mode 100644 templates/config/migrations.twig rename templates/config/{overview.twig => protocol.twig} (68%) diff --git a/config-templates/module_oidc.php b/config-templates/module_oidc.php index bd61a5de..ad985a17 100644 --- a/config-templates/module_oidc.php +++ b/config-templates/module_oidc.php @@ -383,13 +383,13 @@ // Adapter arguments here... ], - // Maximum federation cache item duration. Federation cache item duration will typically be resolved based on the - // expiry of the artifact. For example, when caching entity statements, cache duration will be based on the 'exp' - // claim (expiration time). Since those claims are set by issuer (can be long), it could be desirable to limit - // the maximum time, so that items in cache get refreshed more regularly (and changes propagate more quickly). - // This is only relevant if federation cache adapter is set up. For duration format info, check - // https://www.php.net/manual/en/dateinterval.construct.php. - ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION => 'PT6H', // 6 hours + // Maximum federation cache duration for fetched artifacts. Federation cache duration will typically be resolved + // based on the expiry of the fetched artifact. For example, when caching fetched entity statements, cache + // duration will be based on the 'exp' claim (expiration time). Since those claims are set by issuer (can + // be long), it could be desirable to limit the maximum time, so that items in cache get refreshed more + // regularly (and changes propagate more quickly). This is only relevant if federation cache adapter + // is set up. For duration format info, check https://www.php.net/manual/en/dateinterval.construct.php. + ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED => 'PT6H', // 6 hours /** * PKI settings related to OpenID Federation. These keys will be used, for example, to sign federation @@ -412,10 +412,10 @@ ModuleConfig::OPTION_FEDERATION_ENTITY_STATEMENT_DURATION => 'P1D', // 1 day // Cache duration for federation entity statements produced by this OP. This can be used to avoid calculating JWS - // signature on every HTTP request for OP Configuration statement, Subordinate Statements... - // This is only relevant if federation cache adapter is set up. For duration format info, check + // signature on every HTTP request for OP Configuration statement, Subordinate Statements... This is only + // relevant if federation cache adapter is set up. For duration format info, check // https://www.php.net/manual/en/dateinterval.construct.php. - ModuleConfig::OPTION_FEDERATION_ENTITY_STATEMENT_CACHE_DURATION => 'PT2M', // 2 minutes + ModuleConfig::OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED => 'PT2M', // 2 minutes // Common federation entity parameters: // https://openid.net/specs/openid-federation-1_0.html#name-common-metadata-parameters diff --git a/hooks/hook_adminmenu.php b/hooks/hook_adminmenu.php index ee277374..68238f1e 100644 --- a/hooks/hook_adminmenu.php +++ b/hooks/hook_adminmenu.php @@ -20,7 +20,7 @@ function oidc_hook_adminmenu(Template &$template): void $oidcMenuEntry = [ ModuleConfig::MODULE_NAME => [ - 'url' => $moduleConfig->getModuleUrl(RoutesEnum::AdminConfigOverview->value), + 'url' => $moduleConfig->getModuleUrl(RoutesEnum::AdminConfigProtocol->value), 'name' => Translate::noop('OIDC'), ], ]; diff --git a/public/admin-clients/delete.php b/public/admin-clients/delete.php index a1920f98..22d9d24b 100644 --- a/public/admin-clients/delete.php +++ b/public/admin-clients/delete.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\Client\DeleteController; +use SimpleSAML\Module\oidc\Controllers\Client\DeleteController; use SimpleSAML\Module\oidc\Services\RoutingService; RoutingService::call(DeleteController::class); diff --git a/public/admin-clients/edit.php b/public/admin-clients/edit.php index 133fc0c6..59a6db41 100644 --- a/public/admin-clients/edit.php +++ b/public/admin-clients/edit.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\Client\EditController; +use SimpleSAML\Module\oidc\Controllers\Client\EditController; use SimpleSAML\Module\oidc\Services\RoutingService; RoutingService::call(EditController::class); diff --git a/public/admin-clients/index.php b/public/admin-clients/index.php index f9c02f2b..503d02f0 100644 --- a/public/admin-clients/index.php +++ b/public/admin-clients/index.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\Client\IndexController; +use SimpleSAML\Module\oidc\Controllers\Client\IndexController; use SimpleSAML\Module\oidc\Services\RoutingService; RoutingService::call(IndexController::class); diff --git a/public/admin-clients/new.php b/public/admin-clients/new.php index 0648b0ec..51d1da51 100644 --- a/public/admin-clients/new.php +++ b/public/admin-clients/new.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\Client\CreateController; +use SimpleSAML\Module\oidc\Controllers\Client\CreateController; use SimpleSAML\Module\oidc\Services\RoutingService; RoutingService::call(CreateController::class); diff --git a/public/admin-clients/reset.php b/public/admin-clients/reset.php index f293663a..ffe5a592 100644 --- a/public/admin-clients/reset.php +++ b/public/admin-clients/reset.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\Client\ResetSecretController; +use SimpleSAML\Module\oidc\Controllers\Client\ResetSecretController; use SimpleSAML\Module\oidc\Services\RoutingService; RoutingService::call(ResetSecretController::class); diff --git a/public/admin-clients/show.php b/public/admin-clients/show.php index 28074958..facbc507 100644 --- a/public/admin-clients/show.php +++ b/public/admin-clients/show.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\Client\ShowController; +use SimpleSAML\Module\oidc\Controllers\Client\ShowController; use SimpleSAML\Module\oidc\Services\RoutingService; RoutingService::call(ShowController::class); diff --git a/public/authorize.php b/public/authorize.php index 2b06232d..45cf4b2f 100644 --- a/public/authorize.php +++ b/public/authorize.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\AuthorizationController; +use SimpleSAML\Module\oidc\Controllers\AuthorizationController; use SimpleSAML\Module\oidc\Services\RoutingService; RoutingService::call(AuthorizationController::class, false, true); diff --git a/public/clients/delete.php b/public/clients/delete.php index f0fdc6dc..9f37e0d5 100644 --- a/public/clients/delete.php +++ b/public/clients/delete.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\Client\DeleteController; +use SimpleSAML\Module\oidc\Controllers\Client\DeleteController; use SimpleSAML\Module\oidc\Services\AuthContextService; use SimpleSAML\Module\oidc\Services\RoutingService; diff --git a/public/clients/edit.php b/public/clients/edit.php index c558d9b9..1c860f65 100644 --- a/public/clients/edit.php +++ b/public/clients/edit.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\Client\EditController; +use SimpleSAML\Module\oidc\Controllers\Client\EditController; use SimpleSAML\Module\oidc\Services\AuthContextService; use SimpleSAML\Module\oidc\Services\RoutingService; diff --git a/public/clients/index.php b/public/clients/index.php index d6b649bc..f1181bd5 100644 --- a/public/clients/index.php +++ b/public/clients/index.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\Client\IndexController; +use SimpleSAML\Module\oidc\Controllers\Client\IndexController; use SimpleSAML\Module\oidc\Services\AuthContextService; use SimpleSAML\Module\oidc\Services\RoutingService; diff --git a/public/clients/new.php b/public/clients/new.php index 338c18ad..4d4b944c 100644 --- a/public/clients/new.php +++ b/public/clients/new.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\Client\CreateController; +use SimpleSAML\Module\oidc\Controllers\Client\CreateController; use SimpleSAML\Module\oidc\Services\AuthContextService; use SimpleSAML\Module\oidc\Services\RoutingService; diff --git a/public/clients/reset.php b/public/clients/reset.php index 1dfe4572..88388448 100644 --- a/public/clients/reset.php +++ b/public/clients/reset.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\Client\ResetSecretController; +use SimpleSAML\Module\oidc\Controllers\Client\ResetSecretController; use SimpleSAML\Module\oidc\Services\AuthContextService; use SimpleSAML\Module\oidc\Services\RoutingService; diff --git a/public/clients/show.php b/public/clients/show.php index 29981307..d10c3712 100644 --- a/public/clients/show.php +++ b/public/clients/show.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\Client\ShowController; +use SimpleSAML\Module\oidc\Controllers\Client\ShowController; use SimpleSAML\Module\oidc\Services\AuthContextService; use SimpleSAML\Module\oidc\Services\RoutingService; diff --git a/public/install.php b/public/install.php index c84d42cf..8a64ea29 100644 --- a/public/install.php +++ b/public/install.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\InstallerController; +use SimpleSAML\Module\oidc\Controllers\InstallerController; use SimpleSAML\Module\oidc\Services\RoutingService; RoutingService::call(InstallerController::class); diff --git a/public/jwks.php b/public/jwks.php index 80f71b1f..1abb8823 100644 --- a/public/jwks.php +++ b/public/jwks.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\JwksController; +use SimpleSAML\Module\oidc\Controllers\JwksController; use SimpleSAML\Module\oidc\Services\RoutingService; RoutingService::call(JwksController::class, false, true); diff --git a/public/logout.php b/public/logout.php index da54b710..5e07bd34 100644 --- a/public/logout.php +++ b/public/logout.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\EndSessionController; +use SimpleSAML\Module\oidc\Controllers\EndSessionController; use SimpleSAML\Module\oidc\Services\RoutingService; RoutingService::call(EndSessionController::class, false, true); diff --git a/public/openid-configuration.php b/public/openid-configuration.php index ac91fe44..91a192e8 100644 --- a/public/openid-configuration.php +++ b/public/openid-configuration.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\ConfigurationDiscoveryController; +use SimpleSAML\Module\oidc\Controllers\ConfigurationDiscoveryController; use SimpleSAML\Module\oidc\Services\RoutingService; RoutingService::call(ConfigurationDiscoveryController::class, false, true); diff --git a/public/token.php b/public/token.php index d7730e3e..ed1f7a06 100644 --- a/public/token.php +++ b/public/token.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\AccessTokenController; +use SimpleSAML\Module\oidc\Controllers\AccessTokenController; use SimpleSAML\Module\oidc\Services\RoutingService; RoutingService::call(AccessTokenController::class, false, true); diff --git a/public/userinfo.php b/public/userinfo.php index b24f81c6..ee76b12b 100644 --- a/public/userinfo.php +++ b/public/userinfo.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -use SimpleSAML\Module\oidc\Controller\UserInfoController; +use SimpleSAML\Module\oidc\Controllers\UserInfoController; use SimpleSAML\Module\oidc\Services\RoutingService; RoutingService::call(UserInfoController::class, false, true); diff --git a/routing/routes/routes.php b/routing/routes/routes.php index d6deb499..057f9faa 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -7,37 +7,48 @@ declare(strict_types=1); use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; -use SimpleSAML\Module\oidc\Controller\AccessTokenController; -use SimpleSAML\Module\oidc\Controller\AdminController; -use SimpleSAML\Module\oidc\Controller\AuthorizationController; -use SimpleSAML\Module\oidc\Controller\ConfigurationDiscoveryController; -use SimpleSAML\Module\oidc\Controller\EndSessionController; -use SimpleSAML\Module\oidc\Controller\Federation\EntityStatementController; -use SimpleSAML\Module\oidc\Controller\JwksController; -use SimpleSAML\Module\oidc\Controller\UserInfoController; +use SimpleSAML\Module\oidc\Controllers\AccessTokenController; +use SimpleSAML\Module\oidc\Controllers\Admin\ClientController; +use SimpleSAML\Module\oidc\Controllers\Admin\ConfigController; +use SimpleSAML\Module\oidc\Controllers\AuthorizationController; +use SimpleSAML\Module\oidc\Controllers\ConfigurationDiscoveryController; +use SimpleSAML\Module\oidc\Controllers\EndSessionController; +use SimpleSAML\Module\oidc\Controllers\Federation\EntityStatementController; +use SimpleSAML\Module\oidc\Controllers\JwksController; +use SimpleSAML\Module\oidc\Controllers\UserInfoController; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; /** @psalm-suppress InvalidArgument */ return function (RoutingConfigurator $routes): void { - /** - * Admin area routes. - */ - $routes->add(RoutesEnum::AdminConfigOverview->name, RoutesEnum::AdminConfigOverview->value) - ->controller([AdminController::class, 'configOverview']); - $routes->add(RoutesEnum::AdminRunMigrations->name, RoutesEnum::AdminRunMigrations->value) - ->controller([AdminController::class, 'runMigrations']) + + /***************************************************************************************************************** + * Admin area + ****************************************************************************************************************/ + + $routes->add(RoutesEnum::AdminMigrations->name, RoutesEnum::AdminMigrations->value) + ->controller([ConfigController::class, 'migrations']) + ->methods([HttpMethodsEnum::GET->value]); + $routes->add(RoutesEnum::AdminMigrationsRun->name, RoutesEnum::AdminMigrationsRun->value) + ->controller([ConfigController::class, 'runMigrations']) ->methods([HttpMethodsEnum::POST->value]); + $routes->add(RoutesEnum::AdminConfigProtocol->name, RoutesEnum::AdminConfigProtocol->value) + ->controller([ConfigController::class, 'protocolSettings']); + $routes->add(RoutesEnum::AdminConfigFederation->name, RoutesEnum::AdminConfigFederation->value) + ->controller([ConfigController::class, 'federationSettings']); + + // Client management + + $routes->add(RoutesEnum::AdminClients->name, RoutesEnum::AdminClients->value) + ->controller([ClientController::class, 'index']); + + /***************************************************************************************************************** + * OpenID Connect + ****************************************************************************************************************/ - /** - * OpenID Connect Discovery routes. - */ $routes->add(RoutesEnum::Configuration->name, RoutesEnum::Configuration->value) ->controller(ConfigurationDiscoveryController::class); - /** - * OpenID Connect Core protocol routes. - */ $routes->add(RoutesEnum::Authorization->name, RoutesEnum::Authorization->value) ->controller([AuthorizationController::class, 'authorization']); $routes->add(RoutesEnum::Token->name, RoutesEnum::Token->value) @@ -49,9 +60,10 @@ $routes->add(RoutesEnum::Jwks->name, RoutesEnum::Jwks->value) ->controller([JwksController::class, 'jwks']); - /** - * OpenID Federation related routes. - */ + /***************************************************************************************************************** + * OpenID Federation + ****************************************************************************************************************/ + $routes->add(RoutesEnum::FederationConfiguration->name, RoutesEnum::FederationConfiguration->value) ->controller([EntityStatementController::class, 'configuration']) ->methods([HttpMethodsEnum::GET->value]); @@ -62,5 +74,5 @@ // TODO mivanci delete $routes->add('test', 'test') - ->controller(\SimpleSAML\Module\oidc\Controller\Federation\Test::class); + ->controller(\SimpleSAML\Module\oidc\Controllers\Federation\Test::class); }; diff --git a/routing/services/services.yml b/routing/services/services.yml index f3c093c3..75e6030e 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -18,6 +18,10 @@ services: Psr\Http\Message\UploadedFileFactoryInterface: '@Laminas\Diactoros\UploadedFileFactory' League\OAuth2\Server\AuthorizationValidators\AuthorizationValidatorInterface: '@SimpleSAML\Module\oidc\Server\Validators\BearerTokenValidator' + SimpleSAML\Module\oidc\Controllers\: + resource: '../../src/Controllers/*' + tags: ['controller.service_arguments'] + SimpleSAML\Module\oidc\Services\: resource: '../../src/Services/*' exclude: '../../src/Services/{Container.php}' @@ -29,6 +33,9 @@ services: SimpleSAML\Module\oidc\Factories\: resource: '../../src/Factories/*' + SimpleSAML\Module\oidc\Codebooks\: + resource: '../../src/Codebooks/*' + SimpleSAML\Module\oidc\Admin\: resource: '../../src/Admin/*' @@ -92,6 +99,7 @@ services: factory: ['@SimpleSAML\Module\oidc\Factories\ResourceServerFactory', 'build'] # Utils + SimpleSAML\Module\oidc\Utils\Routes: ~ SimpleSAML\Module\oidc\Utils\RequestParamsResolver: ~ SimpleSAML\Module\oidc\Utils\ClassInstanceBuilder: ~ SimpleSAML\Module\oidc\Utils\JwksResolver: ~ diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 6128f73a..46230586 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -6,18 +6,34 @@ enum RoutesEnum: string { - // Admin area - case AdminConfigOverview = 'admin/config-overview'; - case AdminRunMigrations = 'admin/run-migrations'; + /***************************************************************************************************************** + * Admin area + ****************************************************************************************************************/ + + case AdminConfigProtocol = 'admin/config/protocol'; + case AdminConfigFederation = 'admin/config/federation'; + case AdminMigrations = 'admin/migrations'; + case AdminMigrationsRun = 'admin/migrations/run'; + + // Client management + case AdminClients = 'admin/clients'; - // Protocols - case Authorization = 'authorization'; + /***************************************************************************************************************** + * OpenID Connect + ****************************************************************************************************************/ + case Configuration = '.well-known/openid-configuration'; - case FederationConfiguration = '.well-known/openid-federation'; - case FederationFetch = 'federation/fetch'; - case Jwks = 'jwks'; + case Authorization = 'authorization'; case Token = 'token'; case UserInfo = 'userinfo'; + case Jwks = 'jwks'; case EndSession = 'end-session'; + + /***************************************************************************************************************** + * OpenID Federation + ****************************************************************************************************************/ + + case FederationConfiguration = '.well-known/openid-federation'; + case FederationFetch = 'federation/fetch'; } diff --git a/src/Controller/AccessTokenController.php b/src/Controllers/AccessTokenController.php similarity index 95% rename from src/Controller/AccessTokenController.php rename to src/Controllers/AccessTokenController.php index ba2b7841..4ae09aba 100644 --- a/src/Controller/AccessTokenController.php +++ b/src/Controllers/AccessTokenController.php @@ -13,13 +13,13 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller; +namespace SimpleSAML\Module\oidc\Controllers; use League\OAuth2\Server\Exception\OAuthServerException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; -use SimpleSAML\Module\oidc\Controller\Traits\RequestTrait; +use SimpleSAML\Module\oidc\Controllers\Traits\RequestTrait; use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Server\AuthorizationServer; use SimpleSAML\Module\oidc\Services\ErrorResponder; diff --git a/src/Controllers/Admin/ClientController.php b/src/Controllers/Admin/ClientController.php new file mode 100644 index 00000000..46c333b7 --- /dev/null +++ b/src/Controllers/Admin/ClientController.php @@ -0,0 +1,30 @@ +authorization->requireSspAdmin(true); + } + public function index(): Response + { + return $this->templateFactory->build( + 'oidc:clients.twig', + [ + // + ], + RoutesEnum::AdminClients->value, + ); + } +} diff --git a/src/Controller/AdminController.php b/src/Controllers/Admin/ConfigController.php similarity index 66% rename from src/Controller/AdminController.php rename to src/Controllers/Admin/ConfigController.php index 3b655676..e8af2722 100644 --- a/src/Controller/AdminController.php +++ b/src/Controllers/Admin/ConfigController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SimpleSAML\Module\oidc\Controller; +namespace SimpleSAML\Module\oidc\Controllers\Admin; use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Admin\Authorization; @@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Response; -class AdminController +class ConfigController { public function __construct( protected readonly ModuleConfig $moduleConfig, @@ -26,15 +26,14 @@ public function __construct( $this->authorization->requireSspAdmin(true); } - public function configOverview(): Response + public function migrations(): Response { return $this->templateFactory->build( - 'oidc:config/overview.twig', + 'oidc:config/migrations.twig', [ - 'moduleConfig' => $this->moduleConfig, 'databaseMigration' => $this->databaseMigration, ], - RoutesEnum::AdminConfigOverview->value, + RoutesEnum::AdminMigrations->value, ); } @@ -43,13 +42,35 @@ public function runMigrations(): Response if ($this->databaseMigration->isMigrated()) { $message = Translate::noop('Database is already migrated.'); $this->sessionMessagesService->addMessage($message); - return new RedirectResponse($this->moduleConfig->getModuleUrl(RoutesEnum::AdminConfigOverview->value)); + return new RedirectResponse($this->moduleConfig->getModuleUrl(RoutesEnum::AdminMigrations->value)); } $this->databaseMigration->migrate(); $message = Translate::noop('Database migrated successfully.'); $this->sessionMessagesService->addMessage($message); - return new RedirectResponse($this->moduleConfig->getModuleUrl(RoutesEnum::AdminConfigOverview->value)); + return new RedirectResponse($this->moduleConfig->getModuleUrl(RoutesEnum::AdminMigrations->value)); + } + + public function protocolSettings(): Response + { + return $this->templateFactory->build( + 'oidc:config/protocol.twig', + [ + 'moduleConfig' => $this->moduleConfig, + ], + RoutesEnum::AdminConfigProtocol->value, + ); + } + + public function federationSettings(): Response + { + return $this->templateFactory->build( + 'oidc:config/federation.twig', + [ + 'moduleConfig' => $this->moduleConfig, + ], + RoutesEnum::AdminConfigFederation->value, + ); } } diff --git a/src/Controller/AuthorizationController.php b/src/Controllers/AuthorizationController.php similarity index 99% rename from src/Controller/AuthorizationController.php rename to src/Controllers/AuthorizationController.php index 2c57acb5..07b31be4 100644 --- a/src/Controller/AuthorizationController.php +++ b/src/Controllers/AuthorizationController.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller; +namespace SimpleSAML\Module\oidc\Controllers; use League\OAuth2\Server\Exception\OAuthServerException; use Psr\Http\Message\ResponseInterface; diff --git a/src/Controller/Client/CreateController.php b/src/Controllers/Client/CreateController.php similarity index 99% rename from src/Controller/Client/CreateController.php rename to src/Controllers/Client/CreateController.php index f9b0d93d..a546d5d9 100644 --- a/src/Controller/Client/CreateController.php +++ b/src/Controllers/Client/CreateController.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller\Client; +namespace SimpleSAML\Module\oidc\Controllers\Client; use Laminas\Diactoros\Response\RedirectResponse; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; diff --git a/src/Controller/Client/DeleteController.php b/src/Controllers/Client/DeleteController.php similarity index 95% rename from src/Controller/Client/DeleteController.php rename to src/Controllers/Client/DeleteController.php index 91cdb659..15520cf8 100644 --- a/src/Controller/Client/DeleteController.php +++ b/src/Controllers/Client/DeleteController.php @@ -14,12 +14,12 @@ * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller\Client; +namespace SimpleSAML\Module\oidc\Controllers\Client; use Laminas\Diactoros\Response\RedirectResponse; use Laminas\Diactoros\ServerRequest; use SimpleSAML\Error; -use SimpleSAML\Module\oidc\Controller\Traits\AuthenticatedGetClientFromRequestTrait; +use SimpleSAML\Module\oidc\Controllers\Traits\AuthenticatedGetClientFromRequestTrait; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Services\AuthContextService; diff --git a/src/Controller/Client/EditController.php b/src/Controllers/Client/EditController.php similarity index 97% rename from src/Controller/Client/EditController.php rename to src/Controllers/Client/EditController.php index 6ee8e177..8bb62549 100644 --- a/src/Controller/Client/EditController.php +++ b/src/Controllers/Client/EditController.php @@ -14,11 +14,11 @@ * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller\Client; +namespace SimpleSAML\Module\oidc\Controllers\Client; use Laminas\Diactoros\Response\RedirectResponse; use Laminas\Diactoros\ServerRequest; -use SimpleSAML\Module\oidc\Controller\Traits\AuthenticatedGetClientFromRequestTrait; +use SimpleSAML\Module\oidc\Controllers\Traits\AuthenticatedGetClientFromRequestTrait; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; use SimpleSAML\Module\oidc\Factories\FormFactory; use SimpleSAML\Module\oidc\Factories\TemplateFactory; diff --git a/src/Controller/Client/IndexController.php b/src/Controllers/Client/IndexController.php similarity index 97% rename from src/Controller/Client/IndexController.php rename to src/Controllers/Client/IndexController.php index 1c0ceea1..518a17b1 100644 --- a/src/Controller/Client/IndexController.php +++ b/src/Controllers/Client/IndexController.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller\Client; +namespace SimpleSAML\Module\oidc\Controllers\Client; use Laminas\Diactoros\ServerRequest; use SimpleSAML\Module\oidc\Factories\TemplateFactory; diff --git a/src/Controller/Client/ResetSecretController.php b/src/Controllers/Client/ResetSecretController.php similarity index 94% rename from src/Controller/Client/ResetSecretController.php rename to src/Controllers/Client/ResetSecretController.php index 803d3702..eb730b37 100644 --- a/src/Controller/Client/ResetSecretController.php +++ b/src/Controllers/Client/ResetSecretController.php @@ -14,12 +14,12 @@ * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller\Client; +namespace SimpleSAML\Module\oidc\Controllers\Client; use Laminas\Diactoros\Response\RedirectResponse; use Laminas\Diactoros\ServerRequest; use SimpleSAML\Error; -use SimpleSAML\Module\oidc\Controller\Traits\AuthenticatedGetClientFromRequestTrait; +use SimpleSAML\Module\oidc\Controllers\Traits\AuthenticatedGetClientFromRequestTrait; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Services\AuthContextService; use SimpleSAML\Module\oidc\Services\SessionMessagesService; diff --git a/src/Controller/Client/ShowController.php b/src/Controllers/Client/ShowController.php similarity index 92% rename from src/Controller/Client/ShowController.php rename to src/Controllers/Client/ShowController.php index 02067363..c5e2efd1 100644 --- a/src/Controller/Client/ShowController.php +++ b/src/Controllers/Client/ShowController.php @@ -13,10 +13,10 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller\Client; +namespace SimpleSAML\Module\oidc\Controllers\Client; use Laminas\Diactoros\ServerRequest; -use SimpleSAML\Module\oidc\Controller\Traits\AuthenticatedGetClientFromRequestTrait; +use SimpleSAML\Module\oidc\Controllers\Traits\AuthenticatedGetClientFromRequestTrait; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; diff --git a/src/Controller/ConfigurationDiscoveryController.php b/src/Controllers/ConfigurationDiscoveryController.php similarity index 94% rename from src/Controller/ConfigurationDiscoveryController.php rename to src/Controllers/ConfigurationDiscoveryController.php index 08f3fea1..d3eea6e9 100644 --- a/src/Controller/ConfigurationDiscoveryController.php +++ b/src/Controllers/ConfigurationDiscoveryController.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller; +namespace SimpleSAML\Module\oidc\Controllers; use SimpleSAML\Module\oidc\Services\OpMetadataService; use Symfony\Component\HttpFoundation\JsonResponse; diff --git a/src/Controller/EndSessionController.php b/src/Controllers/EndSessionController.php similarity index 99% rename from src/Controller/EndSessionController.php rename to src/Controllers/EndSessionController.php index 6e935c5d..0e09bece 100644 --- a/src/Controller/EndSessionController.php +++ b/src/Controllers/EndSessionController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SimpleSAML\Module\oidc\Controller; +namespace SimpleSAML\Module\oidc\Controllers; use League\OAuth2\Server\Exception\OAuthServerException; use Psr\Http\Message\ServerRequestInterface; diff --git a/src/Controller/Federation/EntityStatementController.php b/src/Controllers/Federation/EntityStatementController.php similarity index 99% rename from src/Controller/Federation/EntityStatementController.php rename to src/Controllers/Federation/EntityStatementController.php index ea7b0be7..26470c54 100644 --- a/src/Controller/Federation/EntityStatementController.php +++ b/src/Controllers/Federation/EntityStatementController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SimpleSAML\Module\oidc\Controller\Federation; +namespace SimpleSAML\Module\oidc\Controllers\Federation; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\Helpers; diff --git a/src/Controller/Federation/Test.php b/src/Controllers/Federation/Test.php similarity index 99% rename from src/Controller/Federation/Test.php rename to src/Controllers/Federation/Test.php index 0f4477b6..f0e06be4 100644 --- a/src/Controller/Federation/Test.php +++ b/src/Controllers/Federation/Test.php @@ -4,7 +4,7 @@ declare(strict_types=1); -namespace SimpleSAML\Module\oidc\Controller\Federation; +namespace SimpleSAML\Module\oidc\Controllers\Federation; use SimpleSAML\Database; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; diff --git a/src/Controller/InstallerController.php b/src/Controllers/InstallerController.php similarity index 98% rename from src/Controller/InstallerController.php rename to src/Controllers/InstallerController.php index 61899a94..6671c9e4 100644 --- a/src/Controller/InstallerController.php +++ b/src/Controllers/InstallerController.php @@ -13,7 +13,7 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller; +namespace SimpleSAML\Module\oidc\Controllers; use Laminas\Diactoros\Response\RedirectResponse; use Laminas\Diactoros\ServerRequest; diff --git a/src/Controller/JwksController.php b/src/Controllers/JwksController.php similarity index 96% rename from src/Controller/JwksController.php rename to src/Controllers/JwksController.php index cdbf00cd..7159b562 100644 --- a/src/Controller/JwksController.php +++ b/src/Controllers/JwksController.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller; +namespace SimpleSAML\Module\oidc\Controllers; use Laminas\Diactoros\Response\JsonResponse; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; diff --git a/src/Controller/Traits/AuthenticatedGetClientFromRequestTrait.php b/src/Controllers/Traits/AuthenticatedGetClientFromRequestTrait.php similarity index 97% rename from src/Controller/Traits/AuthenticatedGetClientFromRequestTrait.php rename to src/Controllers/Traits/AuthenticatedGetClientFromRequestTrait.php index fd4e627e..2c95bf00 100644 --- a/src/Controller/Traits/AuthenticatedGetClientFromRequestTrait.php +++ b/src/Controllers/Traits/AuthenticatedGetClientFromRequestTrait.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller\Traits; +namespace SimpleSAML\Module\oidc\Controllers\Traits; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Error; diff --git a/src/Controller/Traits/RequestTrait.php b/src/Controllers/Traits/RequestTrait.php similarity index 97% rename from src/Controller/Traits/RequestTrait.php rename to src/Controllers/Traits/RequestTrait.php index 94aa907e..70c2a1b3 100644 --- a/src/Controller/Traits/RequestTrait.php +++ b/src/Controllers/Traits/RequestTrait.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller\Traits; +namespace SimpleSAML\Module\oidc\Controllers\Traits; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; diff --git a/src/Controller/UserInfoController.php b/src/Controllers/UserInfoController.php similarity index 97% rename from src/Controller/UserInfoController.php rename to src/Controllers/UserInfoController.php index 1bf35917..25b39f90 100644 --- a/src/Controller/UserInfoController.php +++ b/src/Controllers/UserInfoController.php @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ -namespace SimpleSAML\Module\oidc\Controller; +namespace SimpleSAML\Module\oidc\Controllers; use Laminas\Diactoros\Response\JsonResponse; use League\OAuth2\Server\Exception\OAuthServerException; @@ -23,7 +23,7 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Error; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; -use SimpleSAML\Module\oidc\Controller\Traits\RequestTrait; +use SimpleSAML\Module\oidc\Controllers\Traits\RequestTrait; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; diff --git a/src/Factories/ClaimTranslatorExtractorFactory.php b/src/Factories/ClaimTranslatorExtractorFactory.php index 9470b5f0..98c1a649 100644 --- a/src/Factories/ClaimTranslatorExtractorFactory.php +++ b/src/Factories/ClaimTranslatorExtractorFactory.php @@ -40,7 +40,7 @@ public function build(): ClaimTranslatorExtractor $translatorTable = $this->moduleConfig->config() ->getOptionalArray(ModuleConfig::OPTION_AUTH_SAML_TO_OIDC_TRANSLATE_TABLE, []); - $privateScopes = $this->moduleConfig->getOpenIDPrivateScopes(); + $privateScopes = $this->moduleConfig->getPrivateScopes(); $claimSet = []; $allowedMultipleValueClaims = []; diff --git a/src/Factories/TemplateFactory.php b/src/Factories/TemplateFactory.php index 24ffdfeb..f2398afe 100644 --- a/src/Factories/TemplateFactory.php +++ b/src/Factories/TemplateFactory.php @@ -23,6 +23,7 @@ use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\SessionMessagesService; +use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\XHTML\Template; class TemplateFactory @@ -36,6 +37,7 @@ public function __construct( protected readonly Menu $oidcMenu, protected readonly SspBridge $sspBridge, protected readonly SessionMessagesService $sessionMessagesService, + protected readonly Routes $routes, ) { } @@ -63,6 +65,7 @@ public function build( 'oidcMenu' => $this->oidcMenu, 'showMenu' => $this->showMenu, 'sessionMessages' => $this->sessionMessagesService->getMessages(), + 'routes' => $this->routes, ]; if ($this->sspBridge->module()->isModuleEnabled('admin')) { @@ -86,15 +89,29 @@ protected function includeDefaultMenuItems(): void { $this->oidcMenu->addItem( $this->oidcMenu->buildItem( - $this->moduleConfig->getModuleUrl(RoutesEnum::AdminConfigOverview->value), - \SimpleSAML\Locale\Translate::noop('Config Overview '), + $this->moduleConfig->getModuleUrl(RoutesEnum::AdminMigrations->value), + Translate::noop('Database Migrations'), + ), + ); + + $this->oidcMenu->addItem( + $this->oidcMenu->buildItem( + $this->moduleConfig->getModuleUrl(RoutesEnum::AdminConfigProtocol->value), + Translate::noop('Protocol Settings'), + ), + ); + + $this->oidcMenu->addItem( + $this->oidcMenu->buildItem( + $this->moduleConfig->getModuleUrl(RoutesEnum::AdminConfigFederation->value), + Translate::noop('Federation Settings'), ), ); $this->oidcMenu->addItem( $this->oidcMenu->buildItem( $this->moduleConfig->getModuleUrl(RoutesEnum::AdminClients->value), - \SimpleSAML\Locale\Translate::noop('Clients '), + Translate::noop('Clients'), ), ); } diff --git a/src/Forms/ClientForm.php b/src/Forms/ClientForm.php index 590848aa..89f69881 100644 --- a/src/Forms/ClientForm.php +++ b/src/Forms/ClientForm.php @@ -394,7 +394,7 @@ protected function getScopes(): array { return array_map( fn(array $item): mixed => $item['description'], - $this->moduleConfig->getOpenIDScopes(), + $this->moduleConfig->getScopes(), ); } diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index daaefb5d..36a9a9bf 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -74,10 +74,10 @@ class ModuleConfig final public const OPTION_FEDERATION_ENABLED = 'federation_enabled'; final public const OPTION_FEDERATION_CACHE_ADAPTER = 'federation_cache_adapter'; final public const OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS = 'federation_cache_adapter_arguments'; - final public const OPTION_FEDERATION_CACHE_MAX_DURATION = 'federation_cache_max_duration'; + final public const OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED = 'federation_cache_max_duration_for_fetched'; final public const OPTION_FEDERATION_TRUST_ANCHORS = 'federation_trust_anchors'; final public const OPTION_FEDERATION_TRUST_MARK_TOKENS = 'federation_trust_mark_tokens'; - final public const OPTION_FEDERATION_ENTITY_STATEMENT_CACHE_DURATION = 'federation_entity_statement_cache_duration'; + final public const OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED = 'federation_cache_duration_for_produced'; final public const OPTION_PROTOCOL_CACHE_ADAPTER = 'protocol_cache_adapter'; final public const OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS = 'protocol_cache_adapter_arguments'; final public const OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION = 'protocol_user_entity_cache_duration'; @@ -130,72 +130,6 @@ public function __construct( $this->validate(); } - /** - * Get SimpleSAMLphp Configuration (config.php) instance. - */ - public function sspConfig(): Configuration - { - return $this->sspConfig; - } - - /** - * Get module config Configuration instance. - */ - public function config(): Configuration - { - return $this->moduleConfig; - } - - /** - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - * @return non-empty-string - */ - public function getIssuer(): string - { - $issuer = $this->config()->getOptionalString(self::OPTION_ISSUER, null) ?? - $this->sspBridge->utils()->http()->getSelfURLHost(); - - if (empty($issuer)) { - throw OidcServerException::serverError('Issuer can not be empty.'); - } - return $issuer; - } - - /** - * @throws \Exception - */ - public function getDefaultAuthSourceId(): string - { - return $this->config()->getString(self::OPTION_AUTH_SOURCE); - } - - public function getModuleUrl(string $path = null): string - { - $base = $this->sspBridge->module()->getModuleURL(self::MODULE_NAME); - - if ($path) { - $base .= "/$path"; - } - - return $base; - } - - /** - * @throws \Exception - */ - public function getOpenIDScopes(): array - { - return array_merge(self::$standardScopes, $this->getOpenIDPrivateScopes()); - } - - /** - * @throws \Exception - */ - public function getOpenIDPrivateScopes(): array - { - return $this->config()->getOptionalArray(self::OPTION_AUTH_CUSTOM_SCOPES, []); - } - /** * @return void * @throws \Exception @@ -204,7 +138,7 @@ public function getOpenIDPrivateScopes(): array */ private function validate(): void { - $privateScopes = $this->getOpenIDPrivateScopes(); + $privateScopes = $this->getPrivateScopes(); array_walk( $privateScopes, /** @@ -237,24 +171,24 @@ function (array $scope, string $name): void { foreach ($authSourcesToAcrValuesMap as $authSource => $acrValues) { if (! is_string($authSource)) { throw new ConfigurationError('Config option authSourcesToAcrValuesMap should have string keys ' . - 'indicating auth sources.'); + 'indicating auth sources.'); } if (! is_array($acrValues)) { throw new ConfigurationError('Config option authSourcesToAcrValuesMap should have array ' . - 'values containing supported ACRs for each auth source key.'); + 'values containing supported ACRs for each auth source key.'); } /** @psalm-suppress MixedAssignment */ foreach ($acrValues as $acrValue) { if (! is_string($acrValue)) { throw new ConfigurationError('Config option authSourcesToAcrValuesMap should have array ' . - 'values with strings only.'); + 'values with strings only.'); } if (! in_array($acrValue, $acrValuesSupported, true)) { throw new ConfigurationError('Config option authSourcesToAcrValuesMap should have ' . - 'supported ACR values only.'); + 'supported ACR values only.'); } } } @@ -264,26 +198,42 @@ function (array $scope, string $name): void { if (! is_null($forcedAcrValueForCookieAuthentication)) { if (! in_array($forcedAcrValueForCookieAuthentication, $acrValuesSupported, true)) { throw new ConfigurationError('Config option forcedAcrValueForCookieAuthentication should have' . - ' null value or string value indicating particular supported ACR.'); + ' null value or string value indicating particular supported ACR.'); } } } + public function moduleName(): string + { + return self::MODULE_NAME; + } + /** - * Get signer for OIDC protocol. - * - * @throws \ReflectionException - * @throws \Exception + * Get SimpleSAMLphp Configuration (config.php) instance. */ - public function getProtocolSigner(): Signer + public function sspConfig(): Configuration { - /** @psalm-var class-string $signerClassname */ - $signerClassname = $this->config()->getOptionalString( - self::OPTION_TOKEN_SIGNER, - Sha256::class, - ); + return $this->sspConfig; + } - return $this->instantiateSigner($signerClassname); + /** + * Get module config Configuration instance. + */ + public function config(): Configuration + { + return $this->moduleConfig; + } + + // TODO mivanci Move to dedicated \SimpleSAML\Module\oidc\Utils\Routes::getModuleUrl + public function getModuleUrl(string $path = null): string + { + $base = $this->sspBridge->module()->getModuleURL(self::MODULE_NAME); + + if ($path) { + $base .= "/$path"; + } + + return $base; } /** @@ -303,18 +253,77 @@ protected function instantiateSigner(string $className): Signer return $signer; } + /***************************************************************************************************************** + * OpenID Connect related config. + ****************************************************************************************************************/ + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @return non-empty-string + */ + public function getIssuer(): string + { + $issuer = $this->config()->getOptionalString(self::OPTION_ISSUER, null) ?? + $this->sspBridge->utils()->http()->getSelfURLHost(); + + if (empty($issuer)) { + throw OidcServerException::serverError('Issuer can not be empty.'); + } + return $issuer; + } + + public function getAuthCodeDuration(): DateInterval + { + return new DateInterval( + $this->config()->getString(self::OPTION_TOKEN_AUTHORIZATION_CODE_TTL), + ); + } + + public function getAccessTokenDuration(): DateInterval + { + return new DateInterval( + $this->config()->getString(self::OPTION_TOKEN_ACCESS_TOKEN_TTL), + ); + } + + public function getRefreshTokenDuration(): DateInterval + { + return new DateInterval( + $this->config()->getString(self::OPTION_TOKEN_REFRESH_TOKEN_TTL), + ); + } + /** - * Get the path to the public certificate used in OIDC protocol. - * @return string The file system path * @throws \Exception */ - public function getProtocolCertPath(): string + public function getDefaultAuthSourceId(): string { - $certName = $this->config()->getOptionalString( - self::OPTION_PKI_CERTIFICATE_FILENAME, - self::DEFAULT_PKI_CERTIFICATE_FILENAME, + return $this->config()->getString(self::OPTION_AUTH_SOURCE); + } + + /** + * @throws \Exception + */ + public function getUserIdentifierAttribute(): string + { + return $this->config()->getString(ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE); + } + + /** + * Get signer for OIDC protocol. + * + * @throws \ReflectionException + * @throws \Exception + */ + public function getProtocolSigner(): Signer + { + /** @psalm-var class-string $signerClassname */ + $signerClassname = $this->config()->getOptionalString( + self::OPTION_TOKEN_SIGNER, + Sha256::class, ); - return $this->sspBridge->utils()->config()->getCertPath($certName); + + return $this->instantiateSigner($signerClassname); } /** @@ -341,23 +350,17 @@ public function getProtocolPrivateKeyPassPhrase(): ?string } /** - * Used in services.yml. - * @return string - */ - public function getEncryptionKey(): string - { - return $this->sspBridge->utils()->config()->getSecretSalt(); - } - - /** - * Get autproc filters defined in the OIDC configuration. - * - * @return array + * Get the path to the public certificate used in OIDC protocol. + * @return string The file system path * @throws \Exception */ - public function getAuthProcFilters(): array + public function getProtocolCertPath(): string { - return $this->config()->getOptionalArray(self::OPTION_AUTH_PROCESSING_FILTERS, []); + $certName = $this->config()->getOptionalString( + self::OPTION_PKI_CERTIFICATE_FILENAME, + self::DEFAULT_PKI_CERTIFICATE_FILENAME, + ); + return $this->sspBridge->utils()->config()->getCertPath($certName); } /** @@ -402,9 +405,72 @@ public function getForcedAcrValueForCookieAuthentication(): ?string /** * @throws \Exception */ - public function getUserIdentifierAttribute(): string + public function getScopes(): array { - return $this->config()->getString(ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE); + return array_merge(self::$standardScopes, $this->getPrivateScopes()); + } + + /** + * @throws \Exception + */ + public function getPrivateScopes(): array + { + return $this->config()->getOptionalArray(self::OPTION_AUTH_CUSTOM_SCOPES, []); + } + + /** + * @return string + */ + public function getEncryptionKey(): string + { + return $this->sspBridge->utils()->config()->getSecretSalt(); + } + + /** + * Get autproc filters defined in the OIDC configuration. + * + * @return array + * @throws \Exception + */ + public function getAuthProcFilters(): array + { + return $this->config()->getOptionalArray(self::OPTION_AUTH_PROCESSING_FILTERS, []); + } + + public function getProtocolCacheAdapterClass(): ?string + { + return $this->config()->getOptionalString(self::OPTION_PROTOCOL_CACHE_ADAPTER, null); + } + + public function getProtocolCacheAdapterArguments(): array + { + return $this->config()->getOptionalArray(self::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS, []); + } + + /** + * Get cache duration for user entities (user data). If not set in configuration, it will fall back to SSP session + * duration. + * + * @throws \Exception + */ + public function getProtocolUserEntityCacheDuration(): DateInterval + { + return new DateInterval( + $this->config()->getOptionalString( + self::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION, + null, + ) ?? "PT{$this->sspConfig()->getInteger('session.duration')}S", + ); + } + + + /***************************************************************************************************************** + * OpenID Connect related config. + ****************************************************************************************************************/ + + public function getFederationEnabled(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_FEDERATION_ENABLED, false); } /** @@ -472,7 +538,7 @@ public function getFederationEntityStatementCacheDuration(): DateInterval { return new DateInterval( $this->config()->getOptionalString( - self::OPTION_FEDERATION_ENTITY_STATEMENT_CACHE_DURATION, + self::OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED, null, ) ?? 'PT2M', ); @@ -538,32 +604,6 @@ public function getHomepageUri(): ?string ); } - public function getAccessTokenDuration(): DateInterval - { - return new DateInterval( - $this->config()->getString(self::OPTION_TOKEN_ACCESS_TOKEN_TTL), - ); - } - - public function getAuthCodeDuration(): DateInterval - { - return new DateInterval( - $this->config()->getString(self::OPTION_TOKEN_AUTHORIZATION_CODE_TTL), - ); - } - - public function getRefreshTokenDuration(): DateInterval - { - return new DateInterval( - $this->config()->getString(self::OPTION_TOKEN_REFRESH_TOKEN_TTL), - ); - } - - public function getFederationEnabled(): bool - { - return $this->config()->getOptionalBoolean(self::OPTION_FEDERATION_ENABLED, false); - } - public function getFederationCacheAdapterClass(): ?string { return $this->config()->getOptionalString(self::OPTION_FEDERATION_CACHE_ADAPTER, null); @@ -577,7 +617,7 @@ public function getFederationCacheAdapterArguments(): array public function getFederationCacheMaxDuration(): DateInterval { return new DateInterval( - $this->config()->getOptionalString(self::OPTION_FEDERATION_CACHE_MAX_DURATION, 'PT6H'), + $this->config()->getOptionalString(self::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED, 'PT6H'), ); } @@ -629,30 +669,4 @@ public function getTrustAnchorJwks(string $trustAnchorId): ?array sprintf('Unexpected JWKS format for Trust Anchor %s: %s', $trustAnchorId, var_export($jwks, true)), ); } - - public function getProtocolCacheAdapterClass(): ?string - { - return $this->config()->getOptionalString(self::OPTION_PROTOCOL_CACHE_ADAPTER, null); - } - - public function getProtocolCacheAdapterArguments(): array - { - return $this->config()->getOptionalArray(self::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS, []); - } - - /** - * Get cache duration for user entities (user data). If not set in configuration, it will fall back to SSP session - * duration. - * - * @throws \Exception - */ - public function getProtocolUserEntityCacheDuration(): DateInterval - { - return new DateInterval( - $this->config()->getOptionalString( - self::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION, - null, - ) ?? "PT{$this->sspConfig()->getInteger('session.duration')}S", - ); - } } diff --git a/src/Repositories/ScopeRepository.php b/src/Repositories/ScopeRepository.php index e623bc89..fbc62aaa 100644 --- a/src/Repositories/ScopeRepository.php +++ b/src/Repositories/ScopeRepository.php @@ -40,7 +40,7 @@ public function __construct( */ public function getScopeEntityByIdentifier($identifier): ScopeEntity|ScopeEntityInterface|null { - $scopes = $this->moduleConfig->getOpenIDScopes(); + $scopes = $this->moduleConfig->getScopes(); if (false === array_key_exists($identifier, $scopes)) { return null; diff --git a/src/Services/AuthenticationService.php b/src/Services/AuthenticationService.php index 18744788..505cc950 100644 --- a/src/Services/AuthenticationService.php +++ b/src/Services/AuthenticationService.php @@ -25,7 +25,7 @@ use SimpleSAML\Error\Exception; use SimpleSAML\Error\NoState; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; -use SimpleSAML\Module\oidc\Controller\EndSessionController; +use SimpleSAML\Module\oidc\Controllers\EndSessionController; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\Factories\AuthSimpleFactory; diff --git a/src/Services/Container.php b/src/Services/Container.php index 639f3f0a..e9a3faa1 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -106,6 +106,7 @@ use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\Module\oidc\Utils\ProtocolCache; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; +use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\Jwks; @@ -156,12 +157,19 @@ public function __construct() $oidcMenu = new Menu(); $this->services[Menu::class] = $oidcMenu; + $routes = new Routes( + $moduleConfig, + $sspBridge, + ); + $this->services[Routes::class] = $routes; + $templateFactory = new TemplateFactory( $simpleSAMLConfiguration, $moduleConfig, $oidcMenu, $sspBridge, $sessionMessagesService, + $routes, ); $this->services[TemplateFactory::class] = $templateFactory; diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 096cfbaf..73b3ece7 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -47,7 +47,7 @@ private function initMetadata(): void $this->metadata[ClaimsEnum::EndSessionEndpoint->value] = $this->moduleConfig->getModuleUrl(RoutesEnum::EndSession->value); $this->metadata[ClaimsEnum::JwksUri->value] = $this->moduleConfig->getModuleUrl(RoutesEnum::Jwks->value); - $this->metadata[ClaimsEnum::ScopesSupported->value] = array_keys($this->moduleConfig->getOpenIDScopes()); + $this->metadata[ClaimsEnum::ScopesSupported->value] = array_keys($this->moduleConfig->getScopes()); $this->metadata[ClaimsEnum::ResponseTypesSupported->value] = ['code', 'token', 'id_token', 'id_token token']; $this->metadata[ClaimsEnum::SubjectTypesSupported->value] = ['public']; $this->metadata[ClaimsEnum::IdTokenSigningAlgValuesSupported->value] = [ diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php new file mode 100644 index 00000000..e49c19bb --- /dev/null +++ b/src/Utils/Routes.php @@ -0,0 +1,105 @@ +moduleConfig->moduleName() . '/' . $resource; + + return $this->sspBridge->module()->getModuleUrl($resource, $parameters); + } + + + /***************************************************************************************************************** + * Admin area + ****************************************************************************************************************/ + + public function urlAdminConfigProtocol(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::AdminConfigProtocol->value, $parameters); + } + + public function urlAdminConfigFederation(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::AdminConfigFederation->value, $parameters); + } + + public function urlAdminMigrations(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::AdminMigrations->value, $parameters); + } + + public function urlAdminMigrationsRun(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::AdminMigrationsRun->value, $parameters); + } + + // Client management + + public function urlAdminClients(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::AdminMigrationsRun->value, $parameters); + } + + /***************************************************************************************************************** + * OpenID Connect + ****************************************************************************************************************/ + + public function urlConfiguration(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::Configuration->value, $parameters); + } + + public function urlAuthorization(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::Authorization->value, $parameters); + } + + public function urlToken(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::Token->value, $parameters); + } + + public function urlUserInfo(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::UserInfo->value, $parameters); + } + + public function urlJwks(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::Jwks->value, $parameters); + } + + public function urlEndSession(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::EndSession->value, $parameters); + } + + /***************************************************************************************************************** + * OpenID Federation + ****************************************************************************************************************/ + + public function urlFederationConfiguration(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::FederationConfiguration->value, $parameters); + } + + public function urlFederationFetch(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::FederationFetch->value, $parameters); + } +} diff --git a/templates/clients.twig b/templates/clients.twig new file mode 100644 index 00000000..20c5957a --- /dev/null +++ b/templates/clients.twig @@ -0,0 +1,9 @@ +{% set subPageTitle = 'Clients'|trans %} + +{% extends "@oidc/base.twig" %} + +{% block oidcContent %} + +// TODO mivanci + +{% endblock oidcContent -%} diff --git a/templates/config/federation.twig b/templates/config/federation.twig new file mode 100644 index 00000000..0ad2a67a --- /dev/null +++ b/templates/config/federation.twig @@ -0,0 +1,89 @@ +{% set subPageTitle = 'Federation Settings'|trans %} + +{% extends "@oidc/base.twig" %} + +{% block oidcContent %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'Setting'|trans }}{{ 'Value'|trans }}
{{ 'Federation Enabled'|trans }} {{ moduleConfig.getFederationEnabled ? 'Yes'|trans : 'No'|trans }}
{{ 'Trust Anchors'|trans }} + // TODO mivanci +
{{ 'Authority Hints'|trans }} + // TODO mivanci +
{{ 'Trust Marks'|trans }} + // TODO mivanci +
{{ 'Signing Algorithm'|trans }} + // TODO mivanci +
{{ 'PKI'|trans }} + // TODO mivanci +
{{ 'Entity Statement Duration'|trans }} + // TODO mivanci +
{{ 'Cache Adapter'|trans }} + // TODO mivanci +
{{ 'Maximum Cache Duration For Fetched Artifacts'|trans }} + // TODO mivanci +
{{ 'Cache Duration For Produced Artifacts'|trans }} + // TODO mivanci +
{{ 'Common Federation Entity Parameters'|trans }} + // TODO mivanci +
+ +
+

{{ 'Entity Configuration URL'|trans }}

+

+ {{ routes.urlFederationConfiguration }} +

+ +{% endblock oidcContent -%} diff --git a/templates/config/migrations.twig b/templates/config/migrations.twig new file mode 100644 index 00000000..007495b5 --- /dev/null +++ b/templates/config/migrations.twig @@ -0,0 +1,30 @@ +{% set subPageTitle = 'Database Migrations'|trans %} + +{% extends "@oidc/base.twig" %} + +{% block oidcContent %} + + {% if databaseMigration.isMigrated %} +

{{ 'All database migrations are implemented.'|trans }}

+ {% else %} +

+ + {% trans %}There are database migrations that have not been implemented. + Use the button below to run them now.{% endtrans %} +

+ +
+ + + +
+
+ {% endif %} + +
+ Before running the migrations, make sure that the database user has proper privileges to change the scheme + (for example, alter, create, drop, index). After running the migrations, it is a good practice to remove + those privileges. +
+ +{% endblock oidcContent -%} diff --git a/templates/config/overview.twig b/templates/config/protocol.twig similarity index 68% rename from templates/config/overview.twig rename to templates/config/protocol.twig index ee695106..d550af2f 100644 --- a/templates/config/overview.twig +++ b/templates/config/protocol.twig @@ -1,30 +1,9 @@ -{% set subPageTitle = 'Config Overview'|trans %} +{% set subPageTitle = 'Protocol Settings'|trans %} {% extends "@oidc/base.twig" %} {% block oidcContent %} -

{{ 'Database Migrations'|trans }}

- - {% if databaseMigration.isMigrated %} -

{{ 'All database migrations are implemented.'|trans }}

- {% else %} -

- - {% trans %}There are database migrations that have not been implemented. - Use the button below to run them now.{% endtrans %} -

- -
- - - -
-
- {% endif %} - -

{{ 'Protocol Settings'|trans }}

- @@ -65,6 +44,10 @@ + + + + - - - - + + + + + + + + + + + + + + + + + + + +
{{ 'User Identifier Attribute'|trans }} {{ moduleConfig.getUserIdentifierAttribute }}
{{ 'Signing Algorithm'|trans }} {{ moduleConfig.getProtocolSigner.algorithmId }}
{{ 'PKI'|trans }} @@ -82,10 +65,6 @@
{{ 'Signing Algorithm'|trans }} {{ moduleConfig.getProtocolSigner.algorithmId }}
{{ 'Supported ACRs'|trans }} @@ -100,26 +79,43 @@ {% endif %}
{{ 'Authentication Sources to ACRs Map'|trans }} + // TODO mivanci +
{{ 'Scopes'|trans }} + // TODO mivanci +
{{ 'Authentication Processing Filters'|trans }} + // TODO mivanci +
{{ 'Protocol Cache Adapter'|trans }} + // TODO mivanci +
{{ 'User Entity Cache Duration'|trans }} + // TODO mivanci +

-

{{ 'Federation Settings'|trans }}

- - - - - - - - - - - - - - - -
{{ 'Setting'|trans }}{{ 'Value'|trans }}
{{ 'Federation Enabled:'|trans }} {{ moduleConfig.getFederationEnabled ? 'Yes'|trans : 'No'|trans }}
+

{{ 'Discovery URL'|trans }}

+

+ {{ routes.urlConfiguration }} +

{% endblock oidcContent -%} diff --git a/tests/unit/src/Controller/AccessTokenControllerTest.php b/tests/unit/src/Controller/AccessTokenControllerTest.php index fa3998a0..1b13f6c0 100644 --- a/tests/unit/src/Controller/AccessTokenControllerTest.php +++ b/tests/unit/src/Controller/AccessTokenControllerTest.php @@ -11,14 +11,14 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; -use SimpleSAML\Module\oidc\Controller\AccessTokenController; -use SimpleSAML\Module\oidc\Controller\Traits\RequestTrait; +use SimpleSAML\Module\oidc\Controllers\AccessTokenController; +use SimpleSAML\Module\oidc\Controllers\Traits\RequestTrait; use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Server\AuthorizationServer; use SimpleSAML\Module\oidc\Services\ErrorResponder; /** - * @covers \SimpleSAML\Module\oidc\Controller\AccessTokenController + * @covers \SimpleSAML\Module\oidc\Controllers\AccessTokenController */ class AccessTokenControllerTest extends TestCase { diff --git a/tests/unit/src/Controller/AuthorizationControllerTest.php b/tests/unit/src/Controller/AuthorizationControllerTest.php index 3f06647c..f8e86932 100644 --- a/tests/unit/src/Controller/AuthorizationControllerTest.php +++ b/tests/unit/src/Controller/AuthorizationControllerTest.php @@ -12,7 +12,7 @@ use Psr\Http\Message\ResponseInterface; use SimpleSAML\Auth\ProcessingChain; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; -use SimpleSAML\Module\oidc\Controller\AuthorizationController; +use SimpleSAML\Module\oidc\Controllers\AuthorizationController; use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\AuthorizationServer; @@ -23,7 +23,7 @@ use SimpleSAML\Module\oidc\Services\LoggerService; /** - * @covers \SimpleSAML\Module\oidc\Controller\AuthorizationController + * @covers \SimpleSAML\Module\oidc\Controllers\AuthorizationController */ class AuthorizationControllerTest extends TestCase { diff --git a/tests/unit/src/Controller/Client/CreateControllerTest.php b/tests/unit/src/Controller/Client/CreateControllerTest.php index 32ef2e24..24a7fb5e 100644 --- a/tests/unit/src/Controller/Client/CreateControllerTest.php +++ b/tests/unit/src/Controller/Client/CreateControllerTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; -use SimpleSAML\Module\oidc\Controller\Client\CreateController; +use SimpleSAML\Module\oidc\Controllers\Client\CreateController; use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; use SimpleSAML\Module\oidc\Factories\FormFactory; @@ -23,7 +23,7 @@ use SimpleSAML\XHTML\Template; /** - * @covers \SimpleSAML\Module\oidc\Controller\Client\CreateController + * @covers \SimpleSAML\Module\oidc\Controllers\Client\CreateController */ class CreateControllerTest extends TestCase { diff --git a/tests/unit/src/Controller/Client/DeleteControllerTest.php b/tests/unit/src/Controller/Client/DeleteControllerTest.php index ebcbe55c..e96139c0 100644 --- a/tests/unit/src/Controller/Client/DeleteControllerTest.php +++ b/tests/unit/src/Controller/Client/DeleteControllerTest.php @@ -11,7 +11,7 @@ use PHPUnit\Framework\TestCase; use Psr\Http\Message\UriInterface; use SimpleSAML\Error\BadRequest; -use SimpleSAML\Module\oidc\Controller\Client\DeleteController; +use SimpleSAML\Module\oidc\Controllers\Client\DeleteController; use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\Repositories\ClientRepository; @@ -20,7 +20,7 @@ use SimpleSAML\XHTML\Template; /** - * @covers \SimpleSAML\Module\oidc\Controller\Client\DeleteController + * @covers \SimpleSAML\Module\oidc\Controllers\Client\DeleteController */ class DeleteControllerTest extends TestCase { diff --git a/tests/unit/src/Controller/Client/EditControllerTest.php b/tests/unit/src/Controller/Client/EditControllerTest.php index 7e22336c..7a1e3f1e 100644 --- a/tests/unit/src/Controller/Client/EditControllerTest.php +++ b/tests/unit/src/Controller/Client/EditControllerTest.php @@ -14,7 +14,7 @@ use Psr\Http\Message\UriInterface; use SimpleSAML\Error\BadRequest; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; -use SimpleSAML\Module\oidc\Controller\Client\EditController; +use SimpleSAML\Module\oidc\Controllers\Client\EditController; use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; use SimpleSAML\Module\oidc\Factories\FormFactory; @@ -29,7 +29,7 @@ use SimpleSAML\XHTML\Template; /** - * @covers \SimpleSAML\Module\oidc\Controller\Client\EditController + * @covers \SimpleSAML\Module\oidc\Controllers\Client\EditController */ class EditControllerTest extends TestCase { diff --git a/tests/unit/src/Controller/Client/IndexControllerTest.php b/tests/unit/src/Controller/Client/IndexControllerTest.php index 476c2b98..698059f4 100644 --- a/tests/unit/src/Controller/Client/IndexControllerTest.php +++ b/tests/unit/src/Controller/Client/IndexControllerTest.php @@ -9,14 +9,14 @@ use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Http\Message\UriInterface; -use SimpleSAML\Module\oidc\Controller\Client\IndexController; +use SimpleSAML\Module\oidc\Controllers\Client\IndexController; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Services\AuthContextService; use SimpleSAML\XHTML\Template; /** - * @covers \SimpleSAML\Module\oidc\Controller\Client\IndexController + * @covers \SimpleSAML\Module\oidc\Controllers\Client\IndexController */ class IndexControllerTest extends TestCase { diff --git a/tests/unit/src/Controller/Client/ResetSecretControllerTest.php b/tests/unit/src/Controller/Client/ResetSecretControllerTest.php index 8d59b742..7129e1b2 100644 --- a/tests/unit/src/Controller/Client/ResetSecretControllerTest.php +++ b/tests/unit/src/Controller/Client/ResetSecretControllerTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Error\BadRequest; -use SimpleSAML\Module\oidc\Controller\Client\ResetSecretController; +use SimpleSAML\Module\oidc\Controllers\Client\ResetSecretController; use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; @@ -17,7 +17,7 @@ use SimpleSAML\Module\oidc\Services\SessionMessagesService; /** - * @covers \SimpleSAML\Module\oidc\Controller\Client\ResetSecretController + * @covers \SimpleSAML\Module\oidc\Controllers\Client\ResetSecretController * * @backupGlobals enabled */ diff --git a/tests/unit/src/Controller/Client/ShowControllerTest.php b/tests/unit/src/Controller/Client/ShowControllerTest.php index 7636e050..52878ebd 100644 --- a/tests/unit/src/Controller/Client/ShowControllerTest.php +++ b/tests/unit/src/Controller/Client/ShowControllerTest.php @@ -8,7 +8,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Error\BadRequest; -use SimpleSAML\Module\oidc\Controller\Client\ShowController; +use SimpleSAML\Module\oidc\Controllers\Client\ShowController; use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; @@ -18,7 +18,7 @@ use SimpleSAML\XHTML\Template; /** - * @covers \SimpleSAML\Module\oidc\Controller\Client\ShowController + * @covers \SimpleSAML\Module\oidc\Controllers\Client\ShowController * * @backupGlobals enabled */ diff --git a/tests/unit/src/Controller/ConfigurationDiscoveryControllerTest.php b/tests/unit/src/Controller/ConfigurationDiscoveryControllerTest.php index 9484b1f9..de62ef24 100644 --- a/tests/unit/src/Controller/ConfigurationDiscoveryControllerTest.php +++ b/tests/unit/src/Controller/ConfigurationDiscoveryControllerTest.php @@ -7,11 +7,11 @@ use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use SimpleSAML\Module\oidc\Controller\ConfigurationDiscoveryController; +use SimpleSAML\Module\oidc\Controllers\ConfigurationDiscoveryController; use SimpleSAML\Module\oidc\Services\OpMetadataService; /** - * @covers \SimpleSAML\Module\oidc\Controller\ConfigurationDiscoveryController + * @covers \SimpleSAML\Module\oidc\Controllers\ConfigurationDiscoveryController */ class ConfigurationDiscoveryControllerTest extends TestCase { diff --git a/tests/unit/src/Controller/EndSessionControllerTest.php b/tests/unit/src/Controller/EndSessionControllerTest.php index f29ceec1..9d3b290d 100644 --- a/tests/unit/src/Controller/EndSessionControllerTest.php +++ b/tests/unit/src/Controller/EndSessionControllerTest.php @@ -13,7 +13,7 @@ use PHPUnit\Framework\TestCase; use SimpleSAML\Error\BadRequest; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; -use SimpleSAML\Module\oidc\Controller\EndSessionController; +use SimpleSAML\Module\oidc\Controllers\EndSessionController; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\Server\AuthorizationServer; use SimpleSAML\Module\oidc\Server\RequestTypes\LogoutRequest; @@ -27,7 +27,7 @@ use Symfony\Component\HttpFoundation\Response; /** - * @covers \SimpleSAML\Module\oidc\Controller\EndSessionController + * @covers \SimpleSAML\Module\oidc\Controllers\EndSessionController */ class EndSessionControllerTest extends TestCase { diff --git a/tests/unit/src/Controller/Federation/EntityStatementControllerTest.php b/tests/unit/src/Controller/Federation/EntityStatementControllerTest.php index a8bbcbdb..90bf6031 100644 --- a/tests/unit/src/Controller/Federation/EntityStatementControllerTest.php +++ b/tests/unit/src/Controller/Federation/EntityStatementControllerTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use SimpleSAML\Module\oidc\Controller\Federation\EntityStatementController; +use SimpleSAML\Module\oidc\Controllers\Federation\EntityStatementController; #[CoversClass(EntityStatementController::class)] class EntityStatementControllerTest extends TestCase diff --git a/tests/unit/src/Controller/InstallerControllerTest.php b/tests/unit/src/Controller/InstallerControllerTest.php index b9caa531..133c4e91 100644 --- a/tests/unit/src/Controller/InstallerControllerTest.php +++ b/tests/unit/src/Controller/InstallerControllerTest.php @@ -8,7 +8,7 @@ use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use SimpleSAML\Module\oidc\Controller\InstallerController; +use SimpleSAML\Module\oidc\Controllers\InstallerController; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\Services\DatabaseLegacyOAuth2Import; use SimpleSAML\Module\oidc\Services\DatabaseMigration; @@ -16,7 +16,7 @@ use SimpleSAML\XHTML\Template; /** - * @covers \SimpleSAML\Module\oidc\Controller\InstallerController + * @covers \SimpleSAML\Module\oidc\Controllers\InstallerController */ class InstallerControllerTest extends TestCase { diff --git a/tests/unit/src/Controller/JwksControllerTest.php b/tests/unit/src/Controller/JwksControllerTest.php index 1788eb60..e1a71cba 100644 --- a/tests/unit/src/Controller/JwksControllerTest.php +++ b/tests/unit/src/Controller/JwksControllerTest.php @@ -8,11 +8,11 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; -use SimpleSAML\Module\oidc\Controller\JwksController; +use SimpleSAML\Module\oidc\Controllers\JwksController; use SimpleSAML\Module\oidc\Services\JsonWebKeySetService; /** - * @covers \SimpleSAML\Module\oidc\Controller\JwksController + * @covers \SimpleSAML\Module\oidc\Controllers\JwksController */ class JwksControllerTest extends TestCase { diff --git a/tests/unit/src/Controller/Traits/RequestTraitTest.php b/tests/unit/src/Controller/Traits/RequestTraitTest.php index 75f8de8a..e2480c05 100644 --- a/tests/unit/src/Controller/Traits/RequestTraitTest.php +++ b/tests/unit/src/Controller/Traits/RequestTraitTest.php @@ -12,12 +12,12 @@ use Psr\Http\Message\ResponseFactoryInterface; use ReflectionMethod; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; -use SimpleSAML\Module\oidc\Controller\Traits\RequestTrait; +use SimpleSAML\Module\oidc\Controllers\Traits\RequestTrait; use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; /** - * @covers \SimpleSAML\Module\oidc\Controller\Traits\RequestTrait + * @covers \SimpleSAML\Module\oidc\Controllers\Traits\RequestTrait */ class RequestTraitTest extends TestCase { diff --git a/tests/unit/src/Controller/UserInfoControllerTest.php b/tests/unit/src/Controller/UserInfoControllerTest.php index 5ad6a7fc..875bdf8d 100644 --- a/tests/unit/src/Controller/UserInfoControllerTest.php +++ b/tests/unit/src/Controller/UserInfoControllerTest.php @@ -11,8 +11,8 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Error\UserNotFound; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; -use SimpleSAML\Module\oidc\Controller\Traits\RequestTrait; -use SimpleSAML\Module\oidc\Controller\UserInfoController; +use SimpleSAML\Module\oidc\Controllers\Traits\RequestTrait; +use SimpleSAML\Module\oidc\Controllers\UserInfoController; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; @@ -22,7 +22,7 @@ use SimpleSAML\Module\oidc\Utils\ClaimTranslatorExtractor; /** - * @covers \SimpleSAML\Module\oidc\Controller\UserInfoController + * @covers \SimpleSAML\Module\oidc\Controllers\UserInfoController */ class UserInfoControllerTest extends TestCase { diff --git a/tests/unit/src/Factories/ClaimTranslatorExtractorFactoryTest.php b/tests/unit/src/Factories/ClaimTranslatorExtractorFactoryTest.php index 5a7dc19b..bd81fef5 100644 --- a/tests/unit/src/Factories/ClaimTranslatorExtractorFactoryTest.php +++ b/tests/unit/src/Factories/ClaimTranslatorExtractorFactoryTest.php @@ -50,7 +50,7 @@ protected function setUp(): void ), ); $this->moduleConfigMock - ->method('getOpenIDPrivateScopes') + ->method('getPrivateScopes') ->willReturn( [ 'customScope1' => [ diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index 334be5db..4c6e0a81 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -134,7 +134,7 @@ public function testCanGetModuleUrl(): void public function testCanGetOpenIdScopes(): void { - $this->assertNotEmpty($this->mock()->getOpenIDScopes()); + $this->assertNotEmpty($this->mock()->getScopes()); } public function testCanGetProtocolSigner(): void diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index 3a6c1f16..c56aa882 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -25,7 +25,7 @@ public function setUp(): void { $this->moduleConfigMock = $this->createMock(ModuleConfig::class); - $this->moduleConfigMock->expects($this->once())->method('getOpenIDScopes') + $this->moduleConfigMock->expects($this->once())->method('getScopes') ->willReturn(['openid' => 'openid']); $this->moduleConfigMock->expects($this->once())->method('getIssuer') ->willReturn('http://localhost'); From 76073b5cea8445f86a1b4a87071fb563ca36b037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Tue, 19 Nov 2024 15:57:52 +0100 Subject: [PATCH 07/17] WIP move to SSP UI --- public/assets/css/src/default.css | 29 ++- routing/routes/routes.php | 2 + src/Admin/Authorization.php | 36 ++- src/Codebooks/RoutesEnum.php | 1 + src/Controllers/Admin/ClientController.php | 60 ++++- src/Controllers/Admin/ConfigController.php | 15 +- .../Federation/EntityStatementController.php | 4 +- src/Factories/FederationFactory.php | 2 +- src/Factories/JwksFactory.php | 2 +- src/Factories/TemplateFactory.php | 2 +- src/ModuleConfig.php | 4 +- src/Utils/Routes.php | 9 +- templates/clients.twig | 104 ++++++++- templates/clients/show-ssp.twig | 99 +++++++++ templates/config/federation.twig | 184 +++++++++------- templates/config/protocol.twig | 205 +++++++++--------- 16 files changed, 555 insertions(+), 203 deletions(-) create mode 100644 templates/clients/show-ssp.twig diff --git a/public/assets/css/src/default.css b/public/assets/css/src/default.css index 60e5b651..612e4f20 100644 --- a/public/assets/css/src/default.css +++ b/public/assets/css/src/default.css @@ -70,10 +70,14 @@ h4 { background-color: #fff; } -ul.config { +ul.disc { list-style: disc outside none; } +em { + font-style: italic; +} + /* Text colors */ .black-text { color: black; } .red-text { color: red; } @@ -85,3 +89,26 @@ ul.config { .cyan-text { color: cyan; } .lightcyan-text { color: lightcyan; } .white-text { color: white; } + +/* Button sizes */ +.button-small { + font-size: 75%; +} + +/* Client Table */ +table.client-table { + width: 100%; +} + +.client-col.col-info { + width: 79%; +} + +.client-col.col-actions { + width: 21%; +} + +.client-col.col-property { + width: 25%; + font-weight: bolder; +} diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 057f9faa..3bf3469a 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -41,6 +41,8 @@ $routes->add(RoutesEnum::AdminClients->name, RoutesEnum::AdminClients->value) ->controller([ClientController::class, 'index']); + $routes->add(RoutesEnum::AdminClientsShow->name, RoutesEnum::AdminClientsShow->value) + ->controller([ClientController::class, 'show']); /***************************************************************************************************************** * OpenID Connect diff --git a/src/Admin/Authorization.php b/src/Admin/Authorization.php index dea36248..4a6c80d1 100644 --- a/src/Admin/Authorization.php +++ b/src/Admin/Authorization.php @@ -8,18 +8,25 @@ use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Exceptions\AuthorizationException; +use SimpleSAML\Module\oidc\Services\AuthContextService; class Authorization { public function __construct( protected readonly SspBridge $sspBridge, + protected readonly AuthContextService $authContextService, ) { } + public function isAdmin(): bool + { + return $this->sspBridge->utils()->auth()->isAdmin(); + } + /** * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException */ - public function requireSspAdmin(bool $forceAdminAuthentication = false): void + public function requireAdmin(bool $forceAdminAuthentication = false): void { if ($forceAdminAuthentication) { try { @@ -33,8 +40,33 @@ public function requireSspAdmin(bool $forceAdminAuthentication = false): void } } - if (! $this->sspBridge->utils()->auth()->isAdmin()) { + if (! $this->isAdmin()) { throw new AuthorizationException(Translate::noop('SimpleSAMLphp admin access required.')); } } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\AuthorizationException + */ + public function requireAdminOrUserWithPermission(string $permission): void + { + if ($this->isAdmin()) { + return; + } + + try { + $this->authContextService->requirePermission($permission); + } catch (Exception $exception) { + throw new AuthorizationException( + Translate::noop('User not authorized.'), + $exception->getCode(), + $exception, + ); + } + } + + public function getUserId(): string + { + return $this->authContextService->getAuthUserId(); + } } diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 46230586..76286aa0 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -18,6 +18,7 @@ enum RoutesEnum: string // Client management case AdminClients = 'admin/clients'; + case AdminClientsShow = 'admin/clients/show'; /***************************************************************************************************************** * OpenID Connect diff --git a/src/Controllers/Admin/ClientController.php b/src/Controllers/Admin/ClientController.php index 46c333b7..9b5d22e6 100644 --- a/src/Controllers/Admin/ClientController.php +++ b/src/Controllers/Admin/ClientController.php @@ -6,7 +6,13 @@ use SimpleSAML\Module\oidc\Admin\Authorization; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; +use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; +use SimpleSAML\Module\oidc\Exceptions\OidcException; use SimpleSAML\Module\oidc\Factories\TemplateFactory; +use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; +use SimpleSAML\Module\oidc\Repositories\ClientRepository; +use SimpleSAML\Module\oidc\Services\AuthContextService; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class ClientController @@ -14,15 +20,63 @@ class ClientController public function __construct( protected readonly TemplateFactory $templateFactory, protected readonly Authorization $authorization, + protected readonly ClientRepository $clientRepository, + protected readonly AllowedOriginRepository $allowedOriginRepository, ) { - $this->authorization->requireSspAdmin(true); + $this->authorization->requireAdminOrUserWithPermission(AuthContextService::PERM_CLIENT); } - public function index(): Response + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + */ + protected function getClientFromRequest(Request $request): ClientEntityInterface { + ($clientId = $request->query->getString('client_id')) + || throw new OidcException('Client ID not provided.'); + + $authedUserId = $this->authorization->isAdmin() ? null : $this->authorization->getUserId(); + + return $this->clientRepository->findById($clientId, $authedUserId) ?? + throw new OidcException('Client not found.'); + } + + public function index(Request $request): Response + { + $page = $request->query->getInt('page', 1); + $query = $request->query->getString('q', ''); + $authedUserId = $this->authorization->isAdmin() ? null : $this->authorization->getUserId(); + + $pagination = $this->clientRepository->findPaginated($page, $query, $authedUserId); + + return $this->templateFactory->build( 'oidc:clients.twig', [ - // + 'clients' => $pagination['items'], + 'numPages' => $pagination['numPages'], + 'currentPage' => $pagination['currentPage'], + 'query' => $query, + ], + RoutesEnum::AdminClients->value, + ); + } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + */ + public function show(Request $request): Response + { + $client = $this->getClientFromRequest($request); + $allowedOrigins = $this->allowedOriginRepository->get($client->getIdentifier()); + + // TODO mivanci rename *-ssp.twig templates after removing old ones. + return $this->templateFactory->build( + 'oidc:clients/show-ssp.twig', + [ + 'client' => $client, + 'allowedOrigins' => $allowedOrigins, ], RoutesEnum::AdminClients->value, ); diff --git a/src/Controllers/Admin/ConfigController.php b/src/Controllers/Admin/ConfigController.php index e8af2722..f87fde1c 100644 --- a/src/Controllers/Admin/ConfigController.php +++ b/src/Controllers/Admin/ConfigController.php @@ -11,6 +11,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\DatabaseMigration; use SimpleSAML\Module\oidc\Services\SessionMessagesService; +use SimpleSAML\OpenID\Federation; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Response; @@ -22,8 +23,9 @@ public function __construct( protected readonly Authorization $authorization, protected readonly DatabaseMigration $databaseMigration, protected readonly SessionMessagesService $sessionMessagesService, + protected readonly Federation $federation, ) { - $this->authorization->requireSspAdmin(true); + $this->authorization->requireAdmin(true); } public function migrations(): Response @@ -65,10 +67,21 @@ public function protocolSettings(): Response public function federationSettings(): Response { + $trustMarks = null; + if (is_array($trustMarkTokens = $this->moduleConfig->getFederationTrustMarkTokens())) { + $trustMarks = array_map( + function (string $token): Federation\TrustMark { + return $this->federation->trustMarkFactory()->fromToken($token); + }, + $trustMarkTokens, + ); + } + return $this->templateFactory->build( 'oidc:config/federation.twig', [ 'moduleConfig' => $this->moduleConfig, + 'trustMarks' => $trustMarks, ], RoutesEnum::AdminConfigFederation->value, ); diff --git a/src/Controllers/Federation/EntityStatementController.php b/src/Controllers/Federation/EntityStatementController.php index 26470c54..5b2211ff 100644 --- a/src/Controllers/Federation/EntityStatementController.php +++ b/src/Controllers/Federation/EntityStatementController.php @@ -158,7 +158,7 @@ public function configuration(): Response $this->federationCache?->set( $entityConfigurationToken, - $this->moduleConfig->getFederationEntityStatementCacheDuration(), + $this->moduleConfig->getFederationEntityStatementCacheDurationForProduced(), self::KEY_OP_ENTITY_CONFIGURATION_STATEMENT, $this->moduleConfig->getIssuer(), ); @@ -253,7 +253,7 @@ public function fetch(Request $request): Response $this->federationCache?->set( $subordinateStatementToken, - $this->moduleConfig->getFederationEntityStatementCacheDuration(), + $this->moduleConfig->getFederationEntityStatementCacheDurationForProduced(), self::KEY_RP_SUBORDINATE_ENTITY_STATEMENT, $subject, ); diff --git a/src/Factories/FederationFactory.php b/src/Factories/FederationFactory.php index 57d93562..96503899 100644 --- a/src/Factories/FederationFactory.php +++ b/src/Factories/FederationFactory.php @@ -40,7 +40,7 @@ public function build(): Federation return new Federation( supportedAlgorithms: $supportedAlgorithms, - maxCacheDuration: $this->moduleConfig->getFederationCacheMaxDuration(), + maxCacheDuration: $this->moduleConfig->getFederationCacheMaxDurationForFetched(), cache: $this->federationCache?->cache, logger: $this->loggerService, ); diff --git a/src/Factories/JwksFactory.php b/src/Factories/JwksFactory.php index 42ecd6ec..5991e17e 100644 --- a/src/Factories/JwksFactory.php +++ b/src/Factories/JwksFactory.php @@ -35,7 +35,7 @@ public function build(): Jwks return new Jwks( supportedAlgorithms: $supportedAlgorithms, - maxCacheDuration: $this->moduleConfig->getFederationCacheMaxDuration(), + maxCacheDuration: $this->moduleConfig->getFederationCacheMaxDurationForFetched(), cache: $this->federationCache?->cache, logger: $this->loggerService, ); diff --git a/src/Factories/TemplateFactory.php b/src/Factories/TemplateFactory.php index f2398afe..3213a771 100644 --- a/src/Factories/TemplateFactory.php +++ b/src/Factories/TemplateFactory.php @@ -111,7 +111,7 @@ protected function includeDefaultMenuItems(): void $this->oidcMenu->addItem( $this->oidcMenu->buildItem( $this->moduleConfig->getModuleUrl(RoutesEnum::AdminClients->value), - Translate::noop('Clients'), + Translate::noop('Client Registry'), ), ); } diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 36a9a9bf..6196ddb2 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -534,7 +534,7 @@ public function getFederationEntityStatementDuration(): DateInterval /** * @throws \Exception */ - public function getFederationEntityStatementCacheDuration(): DateInterval + public function getFederationEntityStatementCacheDurationForProduced(): DateInterval { return new DateInterval( $this->config()->getOptionalString( @@ -614,7 +614,7 @@ public function getFederationCacheAdapterArguments(): array return $this->config()->getOptionalArray(self::OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS, []); } - public function getFederationCacheMaxDuration(): DateInterval + public function getFederationCacheMaxDurationForFetched(): DateInterval { return new DateInterval( $this->config()->getOptionalString(self::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED, 'PT6H'), diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index e49c19bb..a75d2f80 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -23,7 +23,6 @@ public function getModuleUrl(string $resource = '', array $parameters = []): str return $this->sspBridge->module()->getModuleUrl($resource, $parameters); } - /***************************************************************************************************************** * Admin area ****************************************************************************************************************/ @@ -52,7 +51,13 @@ public function urlAdminMigrationsRun(array $parameters = []): string public function urlAdminClients(array $parameters = []): string { - return $this->getModuleUrl(RoutesEnum::AdminMigrationsRun->value, $parameters); + return $this->getModuleUrl(RoutesEnum::AdminClients->value, $parameters); + } + + public function urlAdminClientsShow(string $clientId, array $parameters = []): string + { + $parameters['client_id'] = $clientId; + return $this->getModuleUrl(RoutesEnum::AdminClientsShow->value, $parameters); } /***************************************************************************************************************** diff --git a/templates/clients.twig b/templates/clients.twig index 20c5957a..c071e7a2 100644 --- a/templates/clients.twig +++ b/templates/clients.twig @@ -1,9 +1,109 @@ -{% set subPageTitle = 'Clients'|trans %} +{% set subPageTitle = 'Client Registry'|trans %} {% extends "@oidc/base.twig" %} {% block oidcContent %} -// TODO mivanci +
+
+
+
+ + Reset +
+
+
+ +
+ +
+ {% if clients is empty %} +

+ {{ 'No clients registered.'|trans }} +

+ {% else %} +
+ + + + + + + {% for client in clients %} + + + + + {% endfor %} + +
+ + {{ client.name }} +
+ {{ client.description }} +
+ + {{ 'Registration:'|trans }} {{ client.registrationType.description }} | + {{ 'Created at:'|trans }} {{ client.createdAt ? client.createdAt|date() : 'n/a' }} | + {{ 'Updated at:'|trans }} {{ client.updatedAt ? client.updatedAt|date() : 'n/a' }} | + {{ 'Expires at:'|trans }} {{ client.expiresAt ? client.expiresAt|date() : 'never' }} + +
+ +
+ +
+
+
+ + + + {% for i in range(1, numPages) %} + + {{ i }} + + {% endfor %} + + + +
+ + + +
+
+ {% endif %} {% endblock oidcContent -%} diff --git a/templates/clients/show-ssp.twig b/templates/clients/show-ssp.twig new file mode 100644 index 00000000..9673b222 --- /dev/null +++ b/templates/clients/show-ssp.twig @@ -0,0 +1,99 @@ +{% set subPageTitle = 'Client '|trans ~ client.getIdentifier %} + +{% extends "@oidc/base.twig" %} + +{% block oidcContent %} + +
+
+ + + {{ client.enabled ? 'enabled'|trans : 'disabled'|trans }} + +
+ +
+ +
+ {{ 'Registration:'|trans }} {{ client.registrationType.description }} | + {{ 'Created at:'|trans }} {{ client.createdAt ? client.createdAt|date() : 'n/a' }} | + {{ 'Updated at:'|trans }} {{ client.updatedAt ? client.updatedAt|date() : 'n/a' }} | + {{ 'Expires at:'|trans }} {{ client.expiresAt ? client.expiresAt|date() : 'never' }} +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ 'Name and description'|trans }} + + {{ client.name }}
+ {{ client.description }} +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+
+{% endblock oidcContent -%} diff --git a/templates/config/federation.twig b/templates/config/federation.twig index 0ad2a67a..51a0d116 100644 --- a/templates/config/federation.twig +++ b/templates/config/federation.twig @@ -3,87 +3,117 @@ {% extends "@oidc/base.twig" %} {% block oidcContent %} +

+ {{ 'Federation Enabled'|trans }}: + {{ moduleConfig.getFederationEnabled ? 'Yes'|trans : 'No'|trans }} +

+ +

{{ 'Entity'|trans }}

+

+ {{ 'Configuration URL'|trans }}: + {{ routes.urlFederationConfiguration }} +

+

+ {{ 'Issuer'|trans }}: {{ moduleConfig.getIssuer }} +
+ {{ 'Organization Name'|trans }}: {{ moduleConfig.getOrganizationName }} +
+ {{ 'Logo URI'|trans }}: + {{ moduleConfig.getLogoUri }} +
+ {{ 'Policy URI'|trans }}: + {{ moduleConfig.getPolicyUri }} +
+ {{ 'Homepage URI'|trans }}: + {{ moduleConfig.getHomepageUri }} +
+ {{ 'Contacts'|trans }}: + {% if moduleConfig.getContacts is not empty %} + {% for contact in moduleConfig.getContacts %} +
+ - {{ contact }} + {% endfor %} + {% else %} + {{ 'N/A'|trans }} + {% endif %} +

+

+ {{ 'Entity Statement Duration'|trans }}: + {{ moduleConfig.getFederationEntityStatementDuration|date("%mm %dd %hh %i' %s''") }} +

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +

{{ 'PKI'|trans }}

+

+ {{ 'Private Key'|trans }}: {{ moduleConfig.getFederationPrivateKeyPath }} +
+ {{ 'Private Key Password Set'|trans }}: + {{ moduleConfig.getFederationPrivateKeyPassPhrase ? 'Yes'|trans : 'No'|trans }} +
+ {{ 'Public Key'|trans }}: {{ moduleConfig.getFederationCertPath }} +

+

+ {{ 'Signing Algorithm'|trans }}: {{ moduleConfig.getFederationSigner.algorithmId }} +

- -
{{ 'Setting'|trans }}{{ 'Value'|trans }}
{{ 'Federation Enabled'|trans }} {{ moduleConfig.getFederationEnabled ? 'Yes'|trans : 'No'|trans }}
{{ 'Trust Anchors'|trans }} - // TODO mivanci -
{{ 'Authority Hints'|trans }} - // TODO mivanci -
{{ 'Trust Marks'|trans }} - // TODO mivanci -
{{ 'Signing Algorithm'|trans }} - // TODO mivanci -
{{ 'PKI'|trans }} - // TODO mivanci -
{{ 'Entity Statement Duration'|trans }} - // TODO mivanci -
{{ 'Cache Adapter'|trans }} - // TODO mivanci -
{{ 'Maximum Cache Duration For Fetched Artifacts'|trans }} - // TODO mivanci -
{{ 'Cache Duration For Produced Artifacts'|trans }} - // TODO mivanci -
{{ 'Common Federation Entity Parameters'|trans }} - // TODO mivanci -
+

{{ 'Trust Anchors'|trans }}

+ {% if moduleConfig.getFederationTrustAnchors is not empty %} + {% for trustAnchorId, jwks in moduleConfig.getFederationTrustAnchors %} +

+ - {{ trustAnchorId }} +
+ {{ 'JWKS'|trans }}: + {% if jwks|default is not empty %} + + {{- jwks|json_encode(constant('JSON_PRETTY_PRINT')) -}} + + {% else %} + {{ 'N/A'|trans }} + {% endif %} +

+ {% endfor %} + {% else %} +

{{ 'N/A'|trans }}

+ {% endif %} -
-

{{ 'Entity Configuration URL'|trans }}

+

{{ 'Authority Hints'|trans }}

- {{ routes.urlFederationConfiguration }} + {% if moduleConfig.getFederationAuthorityHints|default is not empty %} + {% for authorityHint in moduleConfig.getFederationAuthorityHints %} + {% if not loop.first %} +
+ {% endif %} + - {{ authorityHint }} + {% endfor %} + {% else %} + {{ 'N/A'|trans }} + {% endif %} +

+ +

{{ 'Trust Marks'|trans }}

+ {% if trustMarks|default is not empty %} + {% for trustMark in trustMarks %} +

+ - {{ trustMark.getPayload.id }} + + {{- trustMark.getPayload|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}} + +

+ {% endfor %} + {% else %} +

{{ 'N/A'|trans }}

+ {% endif %} + +

{{ 'Cache'|trans }}

+

+ {{ 'Cache Adapter'|trans }}: + {{ moduleConfig.getFederationCacheAdapterClass|default('N/A'|trans) }} +
+ {{ 'Maximum Cache Duration For Fetched Artifacts'|trans }}: + {{ moduleConfig.getFederationCacheMaxDurationForFetched|date("%mm %dd %hh %i' %s''") }} +
+ {{ 'Cache Duration For Produced Artifacts'|trans }}: + {{ moduleConfig.getFederationEntityStatementCacheDurationForProduced|date("%mm %dd %hh %i' %s''") }} +

{% endblock oidcContent -%} diff --git a/templates/config/protocol.twig b/templates/config/protocol.twig index d550af2f..1c5c1a1c 100644 --- a/templates/config/protocol.twig +++ b/templates/config/protocol.twig @@ -4,118 +4,107 @@ {% block oidcContent %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ 'Setting'|trans }}{{ 'Value'|trans }}
{{ 'Issuer'|trans }} {{ moduleConfig.getIssuer }}
{{ 'Tokens Time-To-Live'|trans }} -
    -
  • - {{ 'Authorization Code:'|trans }} - {{ moduleConfig.getAuthCodeDuration|date("%mm %dd %hh %i' %s''") }} -
  • -
  • - {{ 'Access Token:'|trans }} - {{ moduleConfig.getAccessTokenDuration|date("%mm %dd %hh %i' %s''") }} -
  • -
  • - {{ 'Refresh Token:'|trans }} - {{ moduleConfig.getRefreshTokenDuration|date("%mm %dd %hh %i' %s''") }} -
  • -
+

{{ 'Entity'|trans }}

+

+ {{ 'Discovery URL'|trans }}: + {{ routes.urlConfiguration }} +

+

+ {{ 'Issuer'|trans }}: {{ moduleConfig.getIssuer }} +

-
{{ 'Default Authentication Source'|trans }} {{ moduleConfig.getDefaultAuthSourceId }}
{{ 'User Identifier Attribute'|trans }} {{ moduleConfig.getUserIdentifierAttribute }}
{{ 'Signing Algorithm'|trans }} {{ moduleConfig.getProtocolSigner.algorithmId }}
{{ 'PKI'|trans }} -
    -
  • Private Key: {{ moduleConfig.getProtocolPrivateKeyPath }}
  • -
  • - Private Key Password Set: - {{ moduleConfig.getProtocolPrivateKeyPassPhrase ? 'Yes'|trans : 'No'|trans }} -
  • -
  • - {{ 'Public Key:'|trans }} - {{ moduleConfig.getProtocolCertPath }} -
  • -
+

{{ 'Tokens Time-To-Live (TTL)'|trans }}

+

+ {{ 'Authorization Code'|trans }}: + {{ moduleConfig.getAuthCodeDuration|date("%mm %dd %hh %i' %s''") }} +
+ {{ 'Access Token'|trans }}: + {{ moduleConfig.getAccessTokenDuration|date("%mm %dd %hh %i' %s''") }} +
+ {{ 'Refresh Token'|trans }}: + {{ moduleConfig.getRefreshTokenDuration|date("%mm %dd %hh %i' %s''") }} +

-
{{ 'Supported ACRs'|trans }} - {% if moduleConfig.getAcrValuesSupported is not empty %} -
    - {% for acr in moduleConfig.getAcrValuesSupported %} -
  • {{ acr }}
  • - {% endfor %} -
- {% else %} - {{ 'None defined'|trans }} - {% endif %} -
{{ 'Authentication Sources to ACRs Map'|trans }} - // TODO mivanci -
{{ 'Scopes'|trans }} - // TODO mivanci -
{{ 'Authentication Processing Filters'|trans }} - // TODO mivanci -
{{ 'Protocol Cache Adapter'|trans }} - // TODO mivanci -
{{ 'User Entity Cache Duration'|trans }} - // TODO mivanci -
+

{{ 'PKI'|trans }}

+

+ {{ 'Private Key'|trans }}: {{ moduleConfig.getProtocolPrivateKeyPath }} +
+ {{ 'Private Key Password Set'|trans }}: + {{ moduleConfig.getProtocolPrivateKeyPassPhrase ? 'Yes'|trans : 'No'|trans }} +
+ {{ 'Public Key'|trans }}: {{ moduleConfig.getProtocolCertPath }} +

+

+ {{ 'Signing Algorithm'|trans }}: {{ moduleConfig.getProtocolSigner.algorithmId }} +

-
-

{{ 'Discovery URL'|trans }}

+

{{ 'Authentication'|trans }}

- {{ routes.urlConfiguration }} + {{ 'Default Authentication Source'|trans }}: {{ moduleConfig.getDefaultAuthSourceId }} +
+ {{ 'User Identifier Attribute'|trans }}: {{ moduleConfig.getUserIdentifierAttribute }} +

+

+ {{ 'Authentication Processing Filters'|trans }}: + {% if moduleConfig.getAuthProcFilters is not empty %} + {% for authproc in moduleConfig.getAuthProcFilters %} +
+ - {{ authproc.class|default('[class-not-set]') }} + {% endfor %} + {% else %} + {{ 'N/A'|trans }} + {% endif %} +

+ +

{{ 'Authentication Context Class References (ACRs)'|trans }}

+

+ {{ 'Supported ACRs'|trans }}: + {% if moduleConfig.getAcrValuesSupported is not empty %} + {% for acr in moduleConfig.getAcrValuesSupported %} +
+ - {{ acr }} + {% endfor %} + {% else %} + {{ 'N/A'|trans }} + {% endif %} + +

+

+ {{ 'Authentication Sources to ACRs Map'|trans }}: + {% if moduleConfig.getAuthSourcesToAcrValuesMap is not empty %} + {% for authsource, acrs in moduleConfig.getAuthSourcesToAcrValuesMap %} +
+ - {{ authsource }}: + {% for acr in acrs %} + {{ acr }}{{ loop.last ? '' : ',' }} + {% endfor %} + {% endfor %} + {% else %} + {{ 'N/A'|trans }} + {% endif %} +

+

+ {{ 'Forced ACR For Cookie Authentication'|trans }}: + {{ moduleConfig.getForcedAcrValueForCookieAuthentication|default('N/A'|trans) }}

+ +

{{ 'Scopes'|trans }}

+

+ {% for scope, claims in moduleConfig.getScopes %} + {{ scope }}{{ loop.last ? '' : ', ' }} + {# TODO mivanci Add claims or extract scopes to sepparate page. #} + {% endfor %} +

+ +

{{ 'Cache'|trans }}

+

+ {{ 'Cache Adapter'|trans }}: + {{ moduleConfig.getProtocolCacheAdapterClass|default('N/A'|trans) }} +
+ {{ 'User Entity Cache Duration'|trans }}: + {{ moduleConfig.getProtocolUserEntityCacheDuration|date("%mm %dd %hh %i' %s''") }} +

+ + {% endblock oidcContent -%} From 260dec933a7129069a6ca716a025323cdcc08131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 25 Nov 2024 15:31:48 +0100 Subject: [PATCH 08/17] WIP Move to SSP UI --- public/assets/css/src/default.css | 2 + public/assets/js/src/default.js | 22 + routing/routes/routes.php | 3 + src/Codebooks/ParametersEnum.php | 10 + src/Codebooks/RoutesEnum.php | 1 + src/Controllers/Admin/ClientController.php | 40 +- src/Controllers/Admin/ConfigController.php | 7 +- src/Controllers/Client/ShowController.php | 2 +- src/Utils/Routes.php | 20 + templates/base.twig | 7 +- templates/clients.twig | 5 +- templates/clients/show-old.twig | 231 +++++++++ templates/clients/show-ssp.twig | 99 ---- templates/clients/show.twig | 461 +++++++++--------- .../Controller/Client/ShowControllerTest.php | 2 +- 15 files changed, 584 insertions(+), 328 deletions(-) create mode 100644 public/assets/js/src/default.js create mode 100644 src/Codebooks/ParametersEnum.php create mode 100644 templates/clients/show-old.twig delete mode 100644 templates/clients/show-ssp.twig diff --git a/public/assets/css/src/default.css b/public/assets/css/src/default.css index 612e4f20..cc42a760 100644 --- a/public/assets/css/src/default.css +++ b/public/assets/css/src/default.css @@ -112,3 +112,5 @@ table.client-table { width: 25%; font-weight: bolder; } + +.confirm-action {} diff --git a/public/assets/js/src/default.js b/public/assets/js/src/default.js new file mode 100644 index 00000000..e6eb33fc --- /dev/null +++ b/public/assets/js/src/default.js @@ -0,0 +1,22 @@ + +(function() { + + // Attach `confirm-action` click event to all elements with the `confirm-action` class. + document.querySelectorAll('.confirm-action').forEach(button => { + button.addEventListener('click', function (event) { + // Get custom confirmation text + const confirmText = this.getAttribute('data-confirm-text') ?? 'Are you sure?'; + // Optional: Retrieve additional data + const itemId = this.getAttribute('data-confirm-id') ?? 'N/A'; + + if (!confirm(confirmText)) { + // Prevent the default action if the user cancels + event.preventDefault(); + } else { + // Optional: Handle confirmed action + console.log( + `Confirmed action "${confirmText}" for item with ID "${itemId}"`); + } + }); + }); +})(); diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 3bf3469a..9c89c99c 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -43,6 +43,9 @@ ->controller([ClientController::class, 'index']); $routes->add(RoutesEnum::AdminClientsShow->name, RoutesEnum::AdminClientsShow->value) ->controller([ClientController::class, 'show']); + $routes->add(RoutesEnum::AdminClientsResetSecret->name, RoutesEnum::AdminClientsResetSecret->value) + ->controller([ClientController::class, 'resetSecret']) + ->methods([HttpMethodsEnum::POST->value]); /***************************************************************************************************************** * OpenID Connect diff --git a/src/Codebooks/ParametersEnum.php b/src/Codebooks/ParametersEnum.php new file mode 100644 index 00000000..bb8630ad --- /dev/null +++ b/src/Codebooks/ParametersEnum.php @@ -0,0 +1,10 @@ +authorization->requireAdminOrUserWithPermission(AuthContextService::PERM_CLIENT); } @@ -33,7 +41,7 @@ public function __construct( */ protected function getClientFromRequest(Request $request): ClientEntityInterface { - ($clientId = $request->query->getString('client_id')) + ($clientId = $request->query->getString(ParametersEnum::ClientId->value)) || throw new OidcException('Client ID not provided.'); $authedUserId = $this->authorization->isAdmin() ? null : $this->authorization->getUserId(); @@ -50,7 +58,6 @@ public function index(Request $request): Response $pagination = $this->clientRepository->findPaginated($page, $query, $authedUserId); - return $this->templateFactory->build( 'oidc:clients.twig', [ @@ -71,9 +78,8 @@ public function show(Request $request): Response $client = $this->getClientFromRequest($request); $allowedOrigins = $this->allowedOriginRepository->get($client->getIdentifier()); - // TODO mivanci rename *-ssp.twig templates after removing old ones. return $this->templateFactory->build( - 'oidc:clients/show-ssp.twig', + 'oidc:clients/show.twig', [ 'client' => $client, 'allowedOrigins' => $allowedOrigins, @@ -81,4 +87,30 @@ public function show(Request $request): Response RoutesEnum::AdminClients->value, ); } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + */ + public function resetSecret(Request $request): Response + { + $client = $this->getClientFromRequest($request); + + $oldSecret = $request->request->get('secret'); + + if ($oldSecret !== $client->getSecret()) { + throw new OidcException('Client secret does not match.'); + } + + $client->restoreSecret($this->sspBridge->utils()->random()->generateID()); + $authedUserId = $this->authorization->isAdmin() ? null : $this->authorization->getUserId(); + $this->clientRepository->update($client, $authedUserId); + + $message = Translate::noop('Client secret has been reset.'); + $this->sessionMessagesService->addMessage($message); + + return $this->routes->getRedirectResponseToModuleUrl( + RoutesEnum::AdminClientsShow->value, + [ParametersEnum::ClientId->value => $client->getIdentifier()], + ); + } } diff --git a/src/Controllers/Admin/ConfigController.php b/src/Controllers/Admin/ConfigController.php index f87fde1c..e5100a1c 100644 --- a/src/Controllers/Admin/ConfigController.php +++ b/src/Controllers/Admin/ConfigController.php @@ -11,8 +11,8 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\DatabaseMigration; use SimpleSAML\Module\oidc\Services\SessionMessagesService; +use SimpleSAML\Module\oidc\Utils\Routes; use SimpleSAML\OpenID\Federation; -use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Response; class ConfigController @@ -24,6 +24,7 @@ public function __construct( protected readonly DatabaseMigration $databaseMigration, protected readonly SessionMessagesService $sessionMessagesService, protected readonly Federation $federation, + protected readonly Routes $routes, ) { $this->authorization->requireAdmin(true); } @@ -44,14 +45,14 @@ public function runMigrations(): Response if ($this->databaseMigration->isMigrated()) { $message = Translate::noop('Database is already migrated.'); $this->sessionMessagesService->addMessage($message); - return new RedirectResponse($this->moduleConfig->getModuleUrl(RoutesEnum::AdminMigrations->value)); + return $this->routes->getRedirectResponseToModuleUrl(RoutesEnum::AdminMigrations->value); } $this->databaseMigration->migrate(); $message = Translate::noop('Database migrated successfully.'); $this->sessionMessagesService->addMessage($message); - return new RedirectResponse($this->moduleConfig->getModuleUrl(RoutesEnum::AdminMigrations->value)); + return $this->routes->getRedirectResponseToModuleUrl(RoutesEnum::AdminMigrations->value); } public function protocolSettings(): Response diff --git a/src/Controllers/Client/ShowController.php b/src/Controllers/Client/ShowController.php index c5e2efd1..ed96c22e 100644 --- a/src/Controllers/Client/ShowController.php +++ b/src/Controllers/Client/ShowController.php @@ -49,7 +49,7 @@ public function __invoke(ServerRequest $request): Template $client = $this->getClientFromRequest($request); $allowedOrigins = $this->allowedOriginRepository->get($client->getIdentifier()); - return $this->templateFactory->build('oidc:clients/show.twig', [ + return $this->templateFactory->build('oidc:clients/show-old.twig', [ 'client' => $client, 'allowedOrigins' => $allowedOrigins, ]); diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index a75d2f80..a5d18061 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -7,6 +7,7 @@ use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\ModuleConfig; +use Symfony\Component\HttpFoundation\RedirectResponse; class Routes { @@ -23,6 +24,19 @@ public function getModuleUrl(string $resource = '', array $parameters = []): str return $this->sspBridge->module()->getModuleUrl($resource, $parameters); } + public function getRedirectResponseToModuleUrl( + string $resource = '', + array $parameters = [], + int $status = 302, + array $headers = [], + ): RedirectResponse { + return new RedirectResponse( + $this->getModuleUrl($resource, $parameters), + $status, + $headers, + ); + } + /***************************************************************************************************************** * Admin area ****************************************************************************************************************/ @@ -60,6 +74,12 @@ public function urlAdminClientsShow(string $clientId, array $parameters = []): s return $this->getModuleUrl(RoutesEnum::AdminClientsShow->value, $parameters); } + public function urlAdminClientsResetSecret(string $clientId, array $parameters = []): string + { + $parameters['client_id'] = $clientId; + return $this->getModuleUrl(RoutesEnum::AdminClientsResetSecret->value, $parameters); + } + /***************************************************************************************************************** * OpenID Connect ****************************************************************************************************************/ diff --git a/templates/base.twig b/templates/base.twig index 4df64ea1..7fd9eafe 100644 --- a/templates/base.twig +++ b/templates/base.twig @@ -38,6 +38,11 @@ {% endblock content -%} -{% block postload %}{% endblock postload %} +{% block postload %} + + {{ parent() }} + + +{% endblock postload %} {% block oidcPostload %}{% endblock %} diff --git a/templates/clients.twig b/templates/clients.twig index c071e7a2..3c0753fe 100644 --- a/templates/clients.twig +++ b/templates/clients.twig @@ -67,7 +67,10 @@ - +
diff --git a/templates/clients/show-old.twig b/templates/clients/show-old.twig new file mode 100644 index 00000000..3992a13f --- /dev/null +++ b/templates/clients/show-old.twig @@ -0,0 +1,231 @@ +{% extends "@oidc/oidc_base.twig" %} + +{% set pagetitle = 'Show OpenID Connect Client' | trans %} + +{% block pre_breadcrump %} + / + {{ 'OpenID Connect Client Registry'|trans }} +{% endblock %} + +{% block content %} +

{{ pagetitle }}

+ + + +
+ {{ 'Registration:'|trans }} {{ client.registrationType.description }} | + {{ 'Created at:'|trans }} {{ client.createdAt ? client.createdAt|date() : 'n/a' }} | + {{ 'Updated at:'|trans }} {{ client.updatedAt ? client.updatedAt|date() : 'n/a' }} | + {{ 'Expires at:'|trans }} {{ client.expiresAt ? client.expiresAt|date() : 'never' }} +
+ + + + + + + + + + + + + + + + + + + + + + + + {% if client.isConfidential %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + {% if client.isConfidential == false %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ '{oidc:client:name}'|trans }} + {{ client.name }} +
{{ '{oidc:client:description}'|trans }}{{ client.description }}
{{ '{oidc:client:state}'|trans }} + + {{ (client.isEnabled ? '{oidc:client:is_enabled}' : '{oidc:client:deactivated}')|trans }} + +
{{ '{oidc:client:type}'|trans }}{{ (client.isConfidential ? '{oidc:client:confidential}' : '{oidc:client:public}')|trans }}
{{ '{oidc:client:identifier}'|trans }}{{ client.identifier }} + +
{{ '{oidc:client:secret}'|trans }} + {{ client.secret }} + +
{{ '{oidc:client:auth_source}'|trans }}{{ client.authSourceId }}
{{ '{oidc:client:redirect_uri}'|trans }} +
    + {% for uri in client.redirectUri %} +
  • {{ uri }}
  • + {% endfor %} +
+
{{ '{oidc:client:scopes}'|trans }} +
    + {% for key, scope in client.scopes %} +
  • {{ scope }}
  • + {% endfor %} +
+
{{ '{oidc:client:backchannel_logout_uri}'|trans }}{{ client.backChannelLogoutUri }}
{{ '{oidc:client:post_logout_redirect_uri}'|trans }} +
    + {% for uri in client.postLogoutRedirectUri %} +
  • {{ uri }}
  • + {% endfor %} +
+
{{ '{oidc:client:allowed_origin}'|trans }} +
    + {% for allowedOrigin in allowedOrigins %} +
  • {{ allowedOrigin }}
  • + {% endfor %} +
+
{{ 'Signed JWKS URI'|trans }}{{ client.signedJwksUri }}
{{ 'JWKS URI'|trans }}{{ client.jwksUri }}
{{ 'JWKS'|trans }} + {% if client.jwks %} +
+
+
+ {{ client.jwks|json_encode(constant('JSON_PRETTY_PRINT')) }} +
+
+
+ {% endif %} +
{{ '{oidc:client:owner}'|trans }}{{ client.owner }}
OpenID Federation related properties
{{ 'Is federated'|trans }}{{ (client.isFederated ? 'Yes' : 'No')|trans }}
{{ 'Entity Identifier'|trans }}{{ client.entityIdentifier }}
{{ 'Client registration types'|trans }} +
    + {% for clientRegistrationType in client.clientRegistrationTypes %} +
  • {{ clientRegistrationType }}
  • + {% endfor %} +
+
{{ 'Federation JWKS'|trans }} + {% if client.federationJwks %} +
+
+
+ {{ client.federationJwks|json_encode(constant('JSON_PRETTY_PRINT')) }} +
+
+
+ {% endif %} +
+
+ + {{ '{oidc:return}'|trans }} + + + {{ '{oidc:edit}'|trans }} + + {% if client.isConfidential %} +
+ {{ '{oidc:client:reset_secret}'|trans }} +
+ + {% endif %} +
+
+ +{% endblock %} + +{% block postload %} + {{ parent() }} + + +{% endblock %} diff --git a/templates/clients/show-ssp.twig b/templates/clients/show-ssp.twig deleted file mode 100644 index 9673b222..00000000 --- a/templates/clients/show-ssp.twig +++ /dev/null @@ -1,99 +0,0 @@ -{% set subPageTitle = 'Client '|trans ~ client.getIdentifier %} - -{% extends "@oidc/base.twig" %} - -{% block oidcContent %} - -
-
- - - {{ client.enabled ? 'enabled'|trans : 'disabled'|trans }} - -
- -
- -
- {{ 'Registration:'|trans }} {{ client.registrationType.description }} | - {{ 'Created at:'|trans }} {{ client.createdAt ? client.createdAt|date() : 'n/a' }} | - {{ 'Updated at:'|trans }} {{ client.updatedAt ? client.updatedAt|date() : 'n/a' }} | - {{ 'Expires at:'|trans }} {{ client.expiresAt ? client.expiresAt|date() : 'never' }} -
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- {{ 'Name and description'|trans }} - - {{ client.name }}
- {{ client.description }} -
- - - -
- - - -
- - - -
- - - -
- - - -
- - - -
-
-{% endblock oidcContent -%} diff --git a/templates/clients/show.twig b/templates/clients/show.twig index 3992a13f..9925c0aa 100644 --- a/templates/clients/show.twig +++ b/templates/clients/show.twig @@ -1,231 +1,256 @@ -{% extends "@oidc/oidc_base.twig" %} +{% set subPageTitle = 'Client '|trans ~ client.getIdentifier %} -{% set pagetitle = 'Show OpenID Connect Client' | trans %} +{% extends "@oidc/base.twig" %} -{% block pre_breadcrump %} - / - {{ 'OpenID Connect Client Registry'|trans }} -{% endblock %} +{% block oidcContent %} -{% block content %} -

{{ pagetitle }}

- - +
+
+ + + {{ client.enabled ? 'enabled'|trans : 'disabled'|trans }} + +
+ +
-
+
{{ 'Registration:'|trans }} {{ client.registrationType.description }} | {{ 'Created at:'|trans }} {{ client.createdAt ? client.createdAt|date() : 'n/a' }} | {{ 'Updated at:'|trans }} {{ client.updatedAt ? client.updatedAt|date() : 'n/a' }} | {{ 'Expires at:'|trans }} {{ client.expiresAt ? client.expiresAt|date() : 'never' }}
- - - - - - - - - - - - - - - - - - - - - - - {% if client.isConfidential %} - - - - - {% endif %} - - - - - - - - - - - - - - - - - - - - - {% if client.isConfidential == false %} - - - - - {% endif %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/templates/clients/_form.twig b/templates/clients/_form-old.twig similarity index 100% rename from templates/clients/_form.twig rename to templates/clients/_form-old.twig diff --git a/templates/clients/new-old.twig b/templates/clients/new-old.twig new file mode 100644 index 00000000..39e30279 --- /dev/null +++ b/templates/clients/new-old.twig @@ -0,0 +1,15 @@ +{% extends "@oidc/clients/_form.twig" %} + +{% set pagetitle = 'Create new OpenID Connect Client' | trans %} + +{% block pre_breadcrump %} + / + {{ 'OpenID Connect Client Registry'|trans }} +{% endblock %} + +{% block action %} +
+ + {{ '{oidc:create}'|trans }} +
+{% endblock %} diff --git a/templates/clients/new.twig b/templates/clients/new.twig index 39e30279..a2c599b0 100644 --- a/templates/clients/new.twig +++ b/templates/clients/new.twig @@ -1,15 +1,9 @@ -{% extends "@oidc/clients/_form.twig" %} +{% set subPageTitle = 'Add Client '|trans %} -{% set pagetitle = 'Create new OpenID Connect Client' | trans %} +{% extends "@oidc/base.twig" %} -{% block pre_breadcrump %} - / - {{ 'OpenID Connect Client Registry'|trans }} -{% endblock %} +{% block oidcContent %} -{% block action %} -
- - {{ '{oidc:create}'|trans }} -
-{% endblock %} + // TODO + +{% endblock oidcContent -%} diff --git a/tests/unit/src/Controller/Client/CreateControllerTest.php b/tests/unit/src/Controller/Client/CreateControllerTest.php index 24a7fb5e..aacabfb1 100644 --- a/tests/unit/src/Controller/Client/CreateControllerTest.php +++ b/tests/unit/src/Controller/Client/CreateControllerTest.php @@ -100,7 +100,7 @@ public function testCanShowNewClientForm(): void $this->templateFactoryMock ->expects($this->once()) ->method('build') - ->with('oidc:clients/new.twig', [ + ->with('oidc:clients/new-old.twig', [ 'form' => $this->clientFormMock, 'regexUri' => ClientForm::REGEX_URI, 'regexAllowedOriginUrl' => ClientForm::REGEX_ALLOWED_ORIGIN_URL, From c7d469962ca21cb583fe2eda232e5a0947d0947a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Tue, 26 Nov 2024 21:53:29 +0100 Subject: [PATCH 10/17] WIP Move to SSP UI --- public/assets/css/src/default.css | 4 + routing/routes/routes.php | 5 +- src/Codebooks/RoutesEnum.php | 1 - src/Controllers/Admin/ClientController.php | 120 +++++++++++++++++++- src/Controllers/Client/CreateController.php | 1 + src/Controllers/Client/EditController.php | 1 + src/Forms/ClientForm.php | 9 +- src/Utils/Routes.php | 5 - templates/clients/includes/form.twig | 32 ++++++ templates/clients/new-old.twig | 2 +- templates/clients/new.twig | 18 ++- 11 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 templates/clients/includes/form.twig diff --git a/public/assets/css/src/default.css b/public/assets/css/src/default.css index cc42a760..33c90a8f 100644 --- a/public/assets/css/src/default.css +++ b/public/assets/css/src/default.css @@ -114,3 +114,7 @@ table.client-table { } .confirm-action {} + +form.pure-form-stacked input { + width: 100%; +} diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 2364cbd4..600053b9 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -43,10 +43,7 @@ ->controller([ClientController::class, 'index']); $routes->add(RoutesEnum::AdminClientsAdd->name, RoutesEnum::AdminClientsAdd->value) ->controller([ClientController::class, 'add']) - ->methods([HttpMethodsEnum::GET->value]); - $routes->add(RoutesEnum::AdminClientsAddPersist->name, RoutesEnum::AdminClientsAddPersist->value) - ->controller([ClientController::class, 'addPersist']) - ->methods([HttpMethodsEnum::POST->value]); + ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); $routes->add(RoutesEnum::AdminClientsShow->name, RoutesEnum::AdminClientsShow->value) ->controller([ClientController::class, 'show']) ->methods([HttpMethodsEnum::GET->value]); diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 233b6565..124539ad 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -20,7 +20,6 @@ enum RoutesEnum: string case AdminClients = 'admin/clients'; case AdminClientsShow = 'admin/clients/show'; case AdminClientsAdd = 'admin/clients/add'; - case AdminClientsAddPersist = 'admin/clients/add/persist'; case AdminClientsResetSecret = 'admin/clients/reset-secret'; case AdminClientsDelete = 'admin/clients/delete'; diff --git a/src/Controllers/Admin/ClientController.php b/src/Controllers/Admin/ClientController.php index c402c109..7a523d10 100644 --- a/src/Controllers/Admin/ClientController.php +++ b/src/Controllers/Admin/ClientController.php @@ -4,16 +4,21 @@ namespace SimpleSAML\Module\oidc\Controllers\Admin; +use Nette\Forms\Form; use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Admin\Authorization; use SimpleSAML\Module\oidc\Bridges\SspBridge; use SimpleSAML\Module\oidc\Codebooks\ParametersEnum; +use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; +use SimpleSAML\Module\oidc\Entities\ClientEntity; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Exceptions\OidcException; +use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; use SimpleSAML\Module\oidc\Factories\FormFactory; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\Forms\ClientForm; +use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Services\AuthContextService; @@ -29,11 +34,13 @@ public function __construct( protected readonly TemplateFactory $templateFactory, protected readonly Authorization $authorization, protected readonly ClientRepository $clientRepository, + protected readonly ClientEntityFactory $clientEntityFactory, protected readonly AllowedOriginRepository $allowedOriginRepository, protected readonly FormFactory $formFactory, protected readonly SspBridge $sspBridge, protected readonly SessionMessagesService $sessionMessagesService, protected readonly Routes $routes, + protected readonly Helpers $helpers, protected readonly LoggerService $logger, ) { $this->authorization->requireAdminOrUserWithPermission(AuthContextService::PERM_CLIENT); @@ -146,15 +153,53 @@ public function delete(Request $request): Response ); } + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Error\Exception + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + */ public function add(): Response { $form = $this->formFactory->build(ClientForm::class); - $form->setAction($this->routes->urlAdminClientsAddPersist()); + + if ($form->isSuccess()) { + $createdAt = $this->helpers->dateTime()->getUtc(); + $updatedAt = $createdAt; + + $owner = $this->authorization->isAdmin() ? null : $this->authorization->getUserId(); + + $client = $this->buildClientFromFormData( + $form, + $this->sspBridge->utils()->random()->generateID(), + $this->sspBridge->utils()->random()->generateID(), + RegistrationTypeEnum::Manual, + $updatedAt, + $createdAt, + null, + $owner, + ); + + $this->clientRepository->add($client); + + // Also persist allowed origins for this client. + is_array($allowedOrigins = $form->getValues('array')['allowed_origin'] ?? []) || + throw new OidcException('Unexpected value for allowed origins.'); + /** @var string[] $allowedOrigins */ + $this->allowedOriginRepository->set($client->getIdentifier(), $allowedOrigins); + + $this->sessionMessagesService->addMessage(Translate::noop('Client has been added.')); + + return $this->routes->getRedirectResponseToModuleUrl( + RoutesEnum::AdminClientsShow->value, + [ParametersEnum::ClientId->value => $client->getIdentifier()], + ); + } return $this->templateFactory->build( 'oidc:clients/new.twig', [ 'form' => $form, + 'actionRoute' => $this->routes->urlAdminClientsAdd(), 'regexUri' => ClientForm::REGEX_URI, 'regexAllowedOriginUrl' => ClientForm::REGEX_ALLOWED_ORIGIN_URL, 'regexHttpUri' => ClientForm::REGEX_HTTP_URI, @@ -163,4 +208,77 @@ public function add(): Response RoutesEnum::AdminClients->value, ); } + + /** + * TODO mivanci Move to ClientEntityFactory::fromRegistrationData on dynamic client registration implementation. + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + */ + protected function buildClientFromFormData( + Form $form, + string $identifier, + string $secret, + RegistrationTypeEnum $registrationType, + \DateTimeImmutable $updatedAt, + ?\DateTimeImmutable $createdAt = null, + ?\DateTimeImmutable $expiresAt = null, + ?string $owner = null, + ): ClientEntityInterface { + /** @var array $data */ + $data = $form->getValues('array'); + + if ( + !is_string($data[ClientEntity::KEY_NAME]) || + !is_string($data[ClientEntity::KEY_DESCRIPTION]) || + !is_array($data[ClientEntity::KEY_REDIRECT_URI]) || + !is_array($data[ClientEntity::KEY_SCOPES]) || + !is_array($data[ClientEntity::KEY_POST_LOGOUT_REDIRECT_URI]) + ) { + throw new OidcException('Invalid Client Entity data'); + } + + /** @var string[] $redirectUris */ + $redirectUris = $data[ClientEntity::KEY_REDIRECT_URI]; + /** @var string[] $scopes */ + $scopes = $data[ClientEntity::KEY_SCOPES]; + /** @var string[] $postLogoutRedirectUris */ + $postLogoutRedirectUris = $data[ClientEntity::KEY_POST_LOGOUT_REDIRECT_URI]; + /** @var ?string[] $clientRegistrationTypes */ + $clientRegistrationTypes = is_array($data[ClientEntity::KEY_CLIENT_REGISTRATION_TYPES]) ? + $data[ClientEntity::KEY_CLIENT_REGISTRATION_TYPES] : null; + /** @var ?array[] $federationJwks */ + $federationJwks = is_array($data[ClientEntity::KEY_FEDERATION_JWKS]) ? + $data[ClientEntity::KEY_FEDERATION_JWKS] : null; + /** @var ?array[] $jwks */ + $jwks = is_array($data[ClientEntity::KEY_JWKS]) ? $data[ClientEntity::KEY_JWKS] : null; + $jwksUri = empty($data[ClientEntity::KEY_JWKS_URI]) ? null : (string)$data[ClientEntity::KEY_JWKS_URI]; + $signedJwksUri = empty($data[ClientEntity::KEY_SIGNED_JWKS_URI]) ? + null : (string)$data[ClientEntity::KEY_SIGNED_JWKS_URI]; + $isFederated = (bool)$data[ClientEntity::KEY_IS_FEDERATED]; + + return $this->clientEntityFactory->fromData( + $identifier, + $secret, + $data['name'], + $data['description'], + $redirectUris, + $scopes, + (bool) $data['is_enabled'], + (bool) $data['is_confidential'], + empty($data['auth_source']) ? null : (string)$data['auth_source'], + $owner, + $postLogoutRedirectUris, + empty($data['backchannel_logout_uri']) ? null : (string)$data['backchannel_logout_uri'], + empty($data['entity_identifier']) ? null : (string)$data['entity_identifier'], + $clientRegistrationTypes, + $federationJwks, + $jwks, + $jwksUri, + $signedJwksUri, + $registrationType, + $updatedAt, + $createdAt, + $expiresAt, + $isFederated, + ); + } } diff --git a/src/Controllers/Client/CreateController.php b/src/Controllers/Client/CreateController.php index f962d00c..bc66b2b1 100644 --- a/src/Controllers/Client/CreateController.php +++ b/src/Controllers/Client/CreateController.php @@ -95,6 +95,7 @@ public function __invoke(): Template|RedirectResponse $jwks = is_array($client['jwks']) ? $client['jwks'] : null; $jwksUri = empty($client['jwks_uri']) ? null : (string)$client['jwks_uri']; $signedJwksUri = empty($client['signed_jwks_uri']) ? null : (string)$client['signed_jwks_uri']; + $registrationType = RegistrationTypeEnum::Manual; $createdAt = $this->helpers->dateTime()->getUtc(); $updatedAt = $createdAt; diff --git a/src/Controllers/Client/EditController.php b/src/Controllers/Client/EditController.php index 8bb62549..5a67a83c 100644 --- a/src/Controllers/Client/EditController.php +++ b/src/Controllers/Client/EditController.php @@ -101,6 +101,7 @@ public function __invoke(ServerRequest $request): Template|RedirectResponse $jwks = is_array($data['jwks']) ? $data['jwks'] : null; $jwksUri = empty($data['jwks_uri']) ? null : (string)$data['jwks_uri']; $signedJwksUri = empty($data['signed_jwks_uri']) ? null : (string)$data['signed_jwks_uri']; + $registrationType = $client->getRegistrationType(); $updatedAt = $this->helpers->dateTime()->getUtc(); $createdAt = $client->getCreatedAt(); diff --git a/src/Forms/ClientForm.php b/src/Forms/ClientForm.php index 89f69881..f1391e10 100644 --- a/src/Forms/ClientForm.php +++ b/src/Forms/ClientForm.php @@ -18,6 +18,7 @@ use Nette\Forms\Form; use SimpleSAML\Auth\Source; +use SimpleSAML\Locale\Translate; use SimpleSAML\Module\oidc\Forms\Controls\CsrfProtection; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\OpenID\Codebooks\ClientRegistrationTypesEnum; @@ -341,11 +342,11 @@ protected function buildForm(): void $this->addText('name', '{oidc:client:name}') ->setMaxLength(255) - ->setRequired('Set a name'); + ->setRequired(Translate::noop('Name is required.')); $this->addTextArea('description', '{oidc:client:description}', null, 5); $this->addTextArea('redirect_uri', '{oidc:client:redirect_uri}', null, 5) - ->setRequired('Write one redirect URI at least'); + ->setRequired(Translate::noop('At least one redirect URI is required.')); $this->addCheckbox('is_enabled', '{oidc:client:is_enabled}'); @@ -355,14 +356,14 @@ protected function buildForm(): void $this->addSelect('auth_source', '{oidc:client:auth_source}:') ->setHtmlAttribute('class', 'ui fluid dropdown clearable') ->setItems(Source::getSources(), false) - ->setPrompt('Pick an AuthSource'); + ->setPrompt(Translate::noop('Pick an AuthSource')); $scopes = $this->getScopes(); $this->addMultiSelect('scopes', '{oidc:client:scopes}') ->setHtmlAttribute('class', 'ui fluid dropdown') ->setItems($scopes) - ->setRequired('Select one scope at least'); + ->setRequired(Translate::noop('At least one scope is required.')); $this->addText('owner', '{oidc:client:owner}') ->setMaxLength(190); diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index 73a35163..260cd01c 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -80,11 +80,6 @@ public function urlAdminClientsAdd(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::AdminClientsAdd->value, $parameters); } - public function urlAdminClientsAddPersist(array $parameters = []): string - { - return $this->getModuleUrl(RoutesEnum::AdminClientsAddPersist->value, $parameters); - } - public function urlAdminClientsResetSecret(string $clientId, array $parameters = []): string { $parameters[ParametersEnum::ClientId->value] = $clientId; diff --git a/templates/clients/includes/form.twig b/templates/clients/includes/form.twig new file mode 100644 index 00000000..54f136e6 --- /dev/null +++ b/templates/clients/includes/form.twig @@ -0,0 +1,32 @@ + +{% if form.hasErrors %} +
+
    + {% for error in form.getErrors %} +
  • {{ error | trans }}
  • + {% endfor %} +
+
+{% endif %} + +

{{ form.hasErrors ? 'yes' : 'no' }}

+ +
+ + {{ form['_token_'].control | raw }} + +
+ + {{ form.name.label |raw }} + {{ form.name.control | raw }} + {% if form.name.hasErrors %} + {{ form.name.getError }} + {% endif %} + + +
+ +
+ \ No newline at end of file diff --git a/templates/clients/new-old.twig b/templates/clients/new-old.twig index 39e30279..9890629f 100644 --- a/templates/clients/new-old.twig +++ b/templates/clients/new-old.twig @@ -1,4 +1,4 @@ -{% extends "@oidc/clients/_form.twig" %} +{% extends "@oidc/clients/_form-old.twig" %} {% set pagetitle = 'Create new OpenID Connect Client' | trans %} diff --git a/templates/clients/new.twig b/templates/clients/new.twig index a2c599b0..9b9d4b4f 100644 --- a/templates/clients/new.twig +++ b/templates/clients/new.twig @@ -1,9 +1,23 @@ -{% set subPageTitle = 'Add Client '|trans %} +{% set subPageTitle = 'Add Client'|trans %} {% extends "@oidc/base.twig" %} {% block oidcContent %} - // TODO +
+
+ +
+ +
+ + {% include "@oidc/clients/includes/form.twig" %} {% endblock oidcContent -%} From bbbfa6f5222890fb3af9a9dbacc39fae23b835b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sun, 1 Dec 2024 17:23:52 +0100 Subject: [PATCH 11/17] WIP Move to SSP UI --- README.md | 11 +- UPGRADE.md | 7 +- docs/oidc.png | Bin 54064 -> 80164 bytes hooks/hook_adminmenu.php | 2 +- hooks/hook_federationpage.php | 14 +- public/assets/css/src/default.css | 2 +- public/assets/js/src/client-form.js | 22 +++ routing/routes/routes.php | 3 + src/Codebooks/RoutesEnum.php | 1 + src/Controllers/Admin/ClientController.php | 70 ++++++- src/Controllers/Client/EditController.php | 2 +- src/Forms/ClientForm.php | 50 +++-- src/Utils/Routes.php | 6 + templates/clients.twig | 2 +- templates/clients/edit-old.twig | 15 ++ templates/clients/edit.twig | 36 ++-- templates/clients/includes/form.twig | 172 +++++++++++++++++- templates/clients/show.twig | 28 ++- .../Controller/Client/EditControllerTest.php | 2 +- 19 files changed, 388 insertions(+), 57 deletions(-) create mode 100644 public/assets/js/src/client-form.js create mode 100644 templates/clients/edit-old.twig diff --git a/README.md b/README.md index 01dec7d9..797bdfea 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ PHP version requirement changes in minor releases for SimpleSAMLphp. ### Upgrading? -If you are upgrading from a previous version, checkout the [upgrade guide](UPGRADE.md). +If you are upgrading from a previous version, make sure to check the [upgrade guide](UPGRADE.md). ## Installation @@ -107,14 +107,12 @@ SimpleSAMLphp configuration file, `config/config.php`. 'oidc' => true, ], -This is required the enable the module on the _Federation_ tab in the admin web interface, which can be used in the -next two steps to finalize the installation. +Once the module is enabled, the database migrations must be run. ### Run database migrations The module comes with some default SQL migrations which set up needed tables in the configured database. To run them, -open the _Federation_ tab from your _SimpleSAMLphp_ installation and select the option _OpenID Connect Installation_ -inside the _Tools_ section. Once there, all you need to do is press the _Install_ button and the schema will be created. +go to `OIDC` > `Database Migrations`, and press the available button. Alternatively, in case of automatic / scripted deployments, you can run the 'install.php' script from the command line: @@ -124,8 +122,7 @@ Alternatively, in case of automatic / scripted deployments, you can run the 'ins The module lets you manage (create, read, update and delete) approved RPs from the module user interface itself. -Once the database schema has been created, you can open the _Federation_ tab from your _SimpleSAMLphp_ installation -and select the option _OpenID Connect Client Registry_ inside the _Tools_ section. +Once the database schema has been created, you can go to `OIDC` > `Client Registry`. Note that clients can be marked as confidential or public. If the client is not marked as confidential (it is public), and is using Authorization Code flow, it will have to provide PKCE parameters during the flow. diff --git a/UPGRADE.md b/UPGRADE.md index 8c7a1cc5..c32408a0 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -79,7 +79,12 @@ key `authproc.oidc` ## Low impact changes -Below are some internal changes that should not have impact for the OIDC OP implementors. However, if you are using +In an effort to move to SimpleSAMLphp way of working with user interface (UI), the client management UI was updated +to extend from the SimpleSAMLphp base template. In addition, we have also introduced some configuration overview pages +where you can take a quick view of some of the configuration values for the module. OIDC related pages are now available +from the main SimpleSAMLphp menu in Administration area. + +Below are also some internal changes that should not have impact for the OIDC OP implementors. However, if you are using this module as a library or extending from it, you will probably encounter breaking changes, since a lot of code has been refactored: diff --git a/docs/oidc.png b/docs/oidc.png index db5e677f422bc8fc1eba45aa88f7ceec75a69519..49d18899bdb8dbacf92d838c8d1e004376930f04 100644 GIT binary patch literal 80164 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

%B%$U1jrEhlju-1c=9W~$JFL- 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ΠDJx5>8Qv7g`Oa1*>?o`lki%Pn67)_pi@s4nR( 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 [ - 'url' => $moduleConfig->getModuleUrl(RoutesEnum::AdminConfigProtocol->value), + 'url' => $moduleConfig->getModuleUrl(RoutesEnum::AdminMigrations->value), 'name' => Translate::noop('OIDC'), ], ]; diff --git a/hooks/hook_federationpage.php b/hooks/hook_federationpage.php index 0a79066b..959c93af 100644 --- a/hooks/hook_federationpage.php +++ b/hooks/hook_federationpage.php @@ -16,6 +16,7 @@ use SimpleSAML\Locale\Translate; use SimpleSAML\Module; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Services\DatabaseMigration; use SimpleSAML\XHTML\Template; @@ -24,12 +25,17 @@ */ function oidc_hook_federationpage(Template $template): void { - $href = Module::getModuleURL('oidc/admin-clients/index.php'); - $text = Translate::noop('OpenID Connect Registry'); + $routes = new Module\oidc\Utils\Routes( + new ModuleConfig(), + new Module\oidc\Bridges\SspBridge(), + ); + + $href = $routes->urlAdminClients(); + $text = Translate::noop('OIDC Client Registry'); if (! (new DatabaseMigration())->isMigrated()) { - $href = Module::getModuleURL('oidc/install.php'); - $text = Translate::noop('OpenID Connect Installation'); + $href = $routes->urlAdminMigrations(); + $text = Translate::noop('OIDC Installation'); } if (!is_array($template->data['links'])) { diff --git a/public/assets/css/src/default.css b/public/assets/css/src/default.css index 33c90a8f..6b13e047 100644 --- a/public/assets/css/src/default.css +++ b/public/assets/css/src/default.css @@ -115,6 +115,6 @@ table.client-table { .confirm-action {} -form.pure-form-stacked input { +form.pure-form-stacked .full-width { width: 100%; } diff --git a/public/assets/js/src/client-form.js b/public/assets/js/src/client-form.js new file mode 100644 index 00000000..34c42979 --- /dev/null +++ b/public/assets/js/src/client-form.js @@ -0,0 +1,22 @@ +(function () { + 'use strict'; + + // Handle enabling and disabling input for 'allowed origins', based on client type radio input. + function toggleAllowedOrigins() { + if (radioOptionPublic.checked) { + inputAllowedOrigin.disabled = false; // Enable the input field + } else if (radioOptionConfidential.checked) { + inputAllowedOrigin.disabled = true; // Disable the input field + } + } + + // Get references to the radio buttons and the input field + const radioOptionPublic = document.getElementById("radio-option-public"); + const radioOptionConfidential = document.getElementById("radio-option-confidential"); + const inputAllowedOrigin = document.getElementById("frm-allowed_origin"); + + radioOptionPublic.addEventListener("change", toggleAllowedOrigins); + radioOptionConfidential.addEventListener("change", toggleAllowedOrigins); + + toggleAllowedOrigins(); +})(); diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 600053b9..12f96743 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -47,6 +47,9 @@ $routes->add(RoutesEnum::AdminClientsShow->name, RoutesEnum::AdminClientsShow->value) ->controller([ClientController::class, 'show']) ->methods([HttpMethodsEnum::GET->value]); + $routes->add(RoutesEnum::AdminClientsEdit->name, RoutesEnum::AdminClientsEdit->value) + ->controller([ClientController::class, 'edit']) + ->methods([HttpMethodsEnum::GET->value, HttpMethodsEnum::POST->value]); $routes->add(RoutesEnum::AdminClientsResetSecret->name, RoutesEnum::AdminClientsResetSecret->value) ->controller([ClientController::class, 'resetSecret']) ->methods([HttpMethodsEnum::POST->value]); diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 124539ad..2184b257 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -19,6 +19,7 @@ enum RoutesEnum: string case AdminClients = 'admin/clients'; case AdminClientsShow = 'admin/clients/show'; + case AdminClientsEdit = 'admin/clients/edit'; case AdminClientsAdd = 'admin/clients/add'; case AdminClientsResetSecret = 'admin/clients/reset-secret'; case AdminClientsDelete = 'admin/clients/delete'; diff --git a/src/Controllers/Admin/ClientController.php b/src/Controllers/Admin/ClientController.php index 7a523d10..00236cf2 100644 --- a/src/Controllers/Admin/ClientController.php +++ b/src/Controllers/Admin/ClientController.php @@ -168,7 +168,7 @@ public function add(): Response $owner = $this->authorization->isAdmin() ? null : $this->authorization->getUserId(); - $client = $this->buildClientFromFormData( + $client = $this->buildClientEntityFromFormData( $form, $this->sspBridge->utils()->random()->generateID(), $this->sspBridge->utils()->random()->generateID(), @@ -179,6 +179,8 @@ public function add(): Response $owner, ); + // TODO mivanci Check if the entity identifier already exists. + $this->clientRepository->add($client); // Also persist allowed origins for this client. @@ -209,11 +211,75 @@ public function add(): Response ); } + /** + * @throws \SimpleSAML\Error\ConfigurationError + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \SimpleSAML\Error\Exception + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + * @throws \JsonException + */ + public function edit(Request $request): Response + { + $originalClient = $this->getClientFromRequest($request); + $clientAllowedOrigins = $this->allowedOriginRepository->get($originalClient->getIdentifier()); + $form = $this->formFactory->build(ClientForm::class); + + $clientData = $originalClient->toArray(); + $clientData['allowed_origin'] = $clientAllowedOrigins; + $form->setDefaults($clientData); + + if ($form->isSuccess()) { + $updatedAt = $this->helpers->dateTime()->getUtc(); + + $updatedClient = $this->buildClientEntityFromFormData( + $form, + $originalClient->getIdentifier(), + $originalClient->getSecret(), + $originalClient->getRegistrationType(), + $updatedAt, + $originalClient->getCreatedAt(), + $originalClient->getExpiresAt(), + $originalClient->getOwner(), + ); + + // TODO mivanci Check if the entity identifier already exists for other client. + + $this->clientRepository->update($updatedClient); + + // Also persist allowed origins for this client. + is_array($allowedOrigins = $form->getValues('array')['allowed_origin'] ?? []) || + throw new OidcException('Unexpected value for allowed origins.'); + /** @var string[] $allowedOrigins */ + $this->allowedOriginRepository->set($originalClient->getIdentifier(), $allowedOrigins); + + $this->sessionMessagesService->addMessage(Translate::noop('Client has been updated.')); + + return $this->routes->getRedirectResponseToModuleUrl( + RoutesEnum::AdminClientsShow->value, + [ParametersEnum::ClientId->value => $originalClient->getIdentifier()], + ); + } + + return $this->templateFactory->build( + 'oidc:clients/edit.twig', + [ + 'originalClient' => $originalClient, + 'form' => $form, + 'actionRoute' => $this->routes->urlAdminClientsEdit($originalClient->getIdentifier()), + 'regexUri' => ClientForm::REGEX_URI, + 'regexAllowedOriginUrl' => ClientForm::REGEX_ALLOWED_ORIGIN_URL, + 'regexHttpUri' => ClientForm::REGEX_HTTP_URI, + 'regexHttpUriPath' => ClientForm::REGEX_HTTP_URI_PATH, + ], + RoutesEnum::AdminClients->value, + ); + } + /** * TODO mivanci Move to ClientEntityFactory::fromRegistrationData on dynamic client registration implementation. * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException */ - protected function buildClientFromFormData( + protected function buildClientEntityFromFormData( Form $form, string $identifier, string $secret, diff --git a/src/Controllers/Client/EditController.php b/src/Controllers/Client/EditController.php index 5a67a83c..ec621165 100644 --- a/src/Controllers/Client/EditController.php +++ b/src/Controllers/Client/EditController.php @@ -144,7 +144,7 @@ public function __invoke(ServerRequest $request): Template|RedirectResponse ); } - return $this->templateFactory->build('oidc:clients/edit.twig', [ + return $this->templateFactory->build('oidc:clients/edit-old.twig', [ 'form' => $form, 'regexUri' => ClientForm::REGEX_URI, 'regexAllowedOriginUrl' => ClientForm::REGEX_ALLOWED_ORIGIN_URL, diff --git a/src/Forms/ClientForm.php b/src/Forms/ClientForm.php index f1391e10..3285f028 100644 --- a/src/Forms/ClientForm.php +++ b/src/Forms/ClientForm.php @@ -341,11 +341,14 @@ protected function buildForm(): void $this->addComponent($this->csrfProtection, Form::ProtectorId); $this->addText('name', '{oidc:client:name}') + ->setHtmlAttribute('class', 'full-width') ->setMaxLength(255) ->setRequired(Translate::noop('Name is required.')); - $this->addTextArea('description', '{oidc:client:description}', null, 5); + $this->addTextArea('description', '{oidc:client:description}', null, 3) + ->setHtmlAttribute('class', 'full-width'); $this->addTextArea('redirect_uri', '{oidc:client:redirect_uri}', null, 5) + ->setHtmlAttribute('class', 'full-width') ->setRequired(Translate::noop('At least one redirect URI is required.')); $this->addCheckbox('is_enabled', '{oidc:client:is_enabled}'); @@ -354,38 +357,49 @@ protected function buildForm(): void // TODO mivanci Source::getSource() move to SSP Bridge. $this->addSelect('auth_source', '{oidc:client:auth_source}:') - ->setHtmlAttribute('class', 'ui fluid dropdown clearable') + ->setHtmlAttribute('class', 'full-width') ->setItems(Source::getSources(), false) - ->setPrompt(Translate::noop('Pick an AuthSource')); + ->setPrompt(Translate::noop('-')); $scopes = $this->getScopes(); - $this->addMultiSelect('scopes', '{oidc:client:scopes}') - ->setHtmlAttribute('class', 'ui fluid dropdown') - ->setItems($scopes) + $this->addMultiSelect('scopes', '{oidc:client:scopes}', $scopes, 10) + ->setHtmlAttribute('class', 'full-width') ->setRequired(Translate::noop('At least one scope is required.')); $this->addText('owner', '{oidc:client:owner}') ->setMaxLength(190); - $this->addTextArea('post_logout_redirect_uri', '{oidc:client:post_logout_redirect_uri}', null, 5); - $this->addTextArea('allowed_origin', '{oidc:client:allowed_origin}', null, 5); + $this->addTextArea('post_logout_redirect_uri', '{oidc:client:post_logout_redirect_uri}', null, 5) + ->setHtmlAttribute('class', 'full-width'); + $this->addTextArea('allowed_origin', '{oidc:client:allowed_origin}', null, 5) + ->setHtmlAttribute('class', 'full-width'); - $this->addText('backchannel_logout_uri', '{oidc:client:backchannel_logout_uri}'); + $this->addText('backchannel_logout_uri', '{oidc:client:backchannel_logout_uri}') + ->setHtmlAttribute('class', 'full-width'); - $this->addText('entity_identifier', 'Entity Identifier'); + $this->addText('entity_identifier', 'Entity Identifier') + ->setHtmlAttribute('class', 'full-width'); - $this->addMultiSelect('client_registration_types', 'Registration types') - ->setHtmlAttribute('class', 'ui fluid dropdown') - ->setItems($this->getClientRegistrationTypes()); + $this->addMultiSelect( + 'client_registration_types', + 'Registration types', + $this->getClientRegistrationTypes(), + 2, + )->setHtmlAttribute('class', 'full-width'); - $this->addTextArea('federation_jwks', '{oidc:client:federation_jwks}', null, 5); + $this->addTextArea('federation_jwks', '{oidc:client:federation_jwks}', null, 5) + ->setHtmlAttribute('class', 'full-width'); - $this->addTextArea('jwks', '{oidc:client:jwks}', null, 5); + $this->addTextArea('jwks', '{oidc:client:jwks}', null, 5) + ->setHtmlAttribute('class', 'full-width'); - $this->addText('jwks_uri', 'JWKS URI'); - $this->addText('signed_jwks_uri', 'Signed JWKS URI'); + $this->addText('jwks_uri', 'JWKS URI') + ->setHtmlAttribute('class', 'full-width'); + $this->addText('signed_jwks_uri', 'Signed JWKS URI') + ->setHtmlAttribute('class', 'full-width'); - $this->addCheckbox('is_federated', '{oidc:client:is_federated}'); + $this->addCheckbox('is_federated', '{oidc:client:is_federated}') + ->setHtmlAttribute('class', 'full-width'); } /** diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index 260cd01c..dc37be86 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -75,6 +75,12 @@ public function urlAdminClientsShow(string $clientId, array $parameters = []): s return $this->getModuleUrl(RoutesEnum::AdminClientsShow->value, $parameters); } + public function urlAdminClientsEdit(string $clientId, array $parameters = []): string + { + $parameters[ParametersEnum::ClientId->value] = $clientId; + return $this->getModuleUrl(RoutesEnum::AdminClientsEdit->value, $parameters); + } + public function urlAdminClientsAdd(array $parameters = []): string { return $this->getModuleUrl(RoutesEnum::AdminClientsAdd->value, $parameters); diff --git a/templates/clients.twig b/templates/clients.twig index 39184c9d..2de33798 100644 --- a/templates/clients.twig +++ b/templates/clients.twig @@ -67,7 +67,7 @@ - + diff --git a/templates/clients/edit-old.twig b/templates/clients/edit-old.twig new file mode 100644 index 00000000..c3dadb70 --- /dev/null +++ b/templates/clients/edit-old.twig @@ -0,0 +1,15 @@ +{% extends "@oidc/clients/_form.twig" %} + +{% set pagetitle = 'Edit new OpenID Connect Client' | trans %} + +{% block pre_breadcrump %} + / + {{ 'OpenID Connect Client Registry'|trans }} +{% endblock %} + +{% block action %} +

+ + {{ '{oidc:save}'|trans }} +
+{% endblock %} diff --git a/templates/clients/edit.twig b/templates/clients/edit.twig index c3dadb70..5ec1ce29 100644 --- a/templates/clients/edit.twig +++ b/templates/clients/edit.twig @@ -1,15 +1,29 @@ -{% extends "@oidc/clients/_form.twig" %} +{% set subPageTitle = 'Edit Client '|trans ~ originalClient.getIdentifier %} -{% set pagetitle = 'Edit new OpenID Connect Client' | trans %} +{% extends "@oidc/base.twig" %} -{% block pre_breadcrump %} - / - {{ 'OpenID Connect Client Registry'|trans }} -{% endblock %} +{% block oidcContent %} -{% block action %} -
- - {{ '{oidc:save}'|trans }} + -{% endblock %} + + {% include "@oidc/clients/includes/form.twig" %} + +{% endblock oidcContent -%} + +{% block postload %} + {{ parent() }} + + +{% endblock %} \ No newline at end of file diff --git a/templates/clients/includes/form.twig b/templates/clients/includes/form.twig index 54f136e6..1eb8fc97 100644 --- a/templates/clients/includes/form.twig +++ b/templates/clients/includes/form.twig @@ -9,8 +9,6 @@
{% endif %} -

{{ form.hasErrors ? 'yes' : 'no' }}

-
@@ -19,14 +17,180 @@
- {{ form.name.label |raw }} {{ form.name.control | raw }} {% if form.name.hasErrors %} {{ form.name.getError }} {% endif %} + + {{ form.description.control | raw }} + {% if form.description.hasErrors %} + {{ form.description.getError }} + {% endif %} + + + + + + + + + + {% trans %}Choose if client is confidential or public. Confidential clients are capable of maintaining the confidentiality of their credentials (e.g., client implemented on a secure server with restricted access to the client credentials), or capable of secure client authentication using other means. Public clients are incapable of maintaining the confidentiality of their credentials (e.g., clients executing on the device used by the resource owner, such as an installed native application or a web browser-based application), and incapable of secure client authentication via any other means.{% endtrans %} + + + + {{ form.redirect_uri.control | raw }} + + {% trans %}Allowed redirect URIs to which the authorization response will be sent. Must be a valid URI, one per line. Example: https://example.org/foo?bar=1{% endtrans %} + + {% if form.redirect_uri.hasErrors %} + {{ form.redirect_uri.getError }} + {% endif %} + + + {{ form.auth_source.control | raw }} + + {% trans %}Authentication source for this particular client. If no authentication source is selected, the default one from configuration file will be used.{% endtrans %} + + {% if form.auth_source.hasErrors %} + {{ form.auth_source.getError }} + {% endif %} + + + {{ form.scopes.control | raw }} + {% if form.scopes.hasErrors %} + {{ form.scopes.getError }} + {% endif %} + + + {{ form.backchannel_logout_uri.control | raw }} + + {% trans %}Enter if client supports Back-Channel Logout specification. When logout is initiated at the OpenID Provider, it will send a Logout Token to this URI in order to notify the client about that event. Must be a valid URI. Example: https://example.org/foo?bar=1{% endtrans %} + + {% if form.backchannel_logout_uri.hasErrors %} + {{ form.backchannel_logout_uri.getError }} + {% endif %} + + + {{ form.post_logout_redirect_uri.control | raw }} + + {% trans %}Allowed redirect URIs to use after client initiated logout. Must be a valid URI, one per line. Example: https://example.org/foo?bar=1{% endtrans %} + + {% if form.post_logout_redirect_uri.hasErrors %} + {{ form.post_logout_redirect_uri.getError }} + {% endif %} + + + {{ form.allowed_origin.control | raw }} + + {% trans %}URLs as allowed origins for CORS requests, for public clients running in browser. Must have http:// or https:// scheme, and at least one 'domain.top-level-domain' pair, or more subdomains. Top-level-domain may end with '.'. No userinfo, path, query or fragment components allowed. May end with port number. One per line. Example: https://example.org{% endtrans %} + + {% if form.allowed_origin.hasErrors %} + {{ form.allowed_origin.getError }} + {% endif %} + + + {{ form.signed_jwks_uri.control | raw }} + + {% trans %}URL to a JWS document containing protocol public keys in JWKS format (claim 'keys'). Example: https://example.org/signed-jwks{% endtrans %} + + {% if form.signed_jwks_uri.hasErrors %} + {{ form.signed_jwks_uri.getError }} + {% endif %} + + + {{ form.jwks_uri.control | raw }} + + {% trans %}URL to a JWKS document containing protocol public keys. Will be used if Signed JWKS URI is not set. Example: https://example.org/jwks{% endtrans %} + + {% if form.jwks_uri.hasErrors %} + {{ form.jwks_uri.getError }} + {% endif %} + + + {{ form.jwks.control | raw }} + + {% trans %}JSON object (string) representing JWKS document containing protocol public keys. Note that this should be different from Federation JWKS. Will be used if JWKS URI is not set. Example: {"keys":[{"kty": "RSA","n": "...","e": "AQAB","kid": "pro123","use": "sig","alg": "RS256"}]}{% endtrans %} + + {% if form.jwks.hasErrors %} + {{ form.jwks.getError }} + {% endif %} + +
+

{{ 'OpenID Federation Related Properties'|trans }}

+ + + + + + {% trans %}Choose if the client is allowed to participate in federation context or not.{% endtrans %} + + + + {{ form.entity_identifier.control | raw }} + + {% trans %}A globally unique URI that is bound to the entity. URI must have https or http scheme and host / domain. It can contain path, but no query, or fragment component.{% endtrans %} + + {% if form.entity_identifier.hasErrors %} + {{ form.entity_identifier.getError }} + {% endif %} + + + {{ form.client_registration_types.control | raw }} + + {% trans %}One or more values from the list. If not selected, falls back to 'automatic'{% endtrans %} + + {% if form.client_registration_types.hasErrors %} + {{ form.client_registration_types.getError }} + {% endif %} + + + {{ form.federation_jwks.control | raw }} + + {% trans %}JSON object (string) representing federation JWKS. This can be used, for example, in entity statements. Note that this should be different from Protocol JWKS. Example: {"keys":[{"kty": "RSA","n": "...","e": "AQAB","kid": "fed123","use": "sig","alg": "RS256"}]}{% endtrans %} + + {% if form.federation_jwks.hasErrors %} + {{ form.federation_jwks.getError }} + {% endif %}
- \ No newline at end of file + diff --git a/templates/clients/show.twig b/templates/clients/show.twig index 9925c0aa..525c8c1c 100644 --- a/templates/clients/show.twig +++ b/templates/clients/show.twig @@ -13,10 +13,28 @@ @@ -146,7 +164,7 @@
{{ '{oidc:client:name}'|trans }} - {{ client.name }} -
{{ '{oidc:client:description}'|trans }}{{ client.description }}
{{ '{oidc:client:state}'|trans }} - - {{ (client.isEnabled ? '{oidc:client:is_enabled}' : '{oidc:client:deactivated}')|trans }} - -
{{ '{oidc:client:type}'|trans }}{{ (client.isConfidential ? '{oidc:client:confidential}' : '{oidc:client:public}')|trans }}
{{ '{oidc:client:identifier}'|trans }}{{ client.identifier }} - -
{{ '{oidc:client:secret}'|trans }} - {{ client.secret }} - -
{{ '{oidc:client:auth_source}'|trans }}{{ client.authSourceId }}
{{ '{oidc:client:redirect_uri}'|trans }} -
    - {% for uri in client.redirectUri %} -
  • {{ uri }}
  • - {% endfor %} -
-
{{ '{oidc:client:scopes}'|trans }} -
    - {% for key, scope in client.scopes %} -
  • {{ scope }}
  • - {% endfor %} -
-
{{ '{oidc:client:backchannel_logout_uri}'|trans }}{{ client.backChannelLogoutUri }}
{{ '{oidc:client:post_logout_redirect_uri}'|trans }} -
    - {% for uri in client.postLogoutRedirectUri %} -
  • {{ uri }}
  • - {% endfor %} -
-
{{ '{oidc:client:allowed_origin}'|trans }} -
    - {% for allowedOrigin in allowedOrigins %} -
  • {{ allowedOrigin }}
  • - {% endfor %} -
-
{{ 'Signed JWKS URI'|trans }}{{ client.signedJwksUri }}
{{ 'JWKS URI'|trans }}{{ client.jwksUri }}
{{ 'JWKS'|trans }} - {% if client.jwks %} -
-
-
- {{ client.jwks|json_encode(constant('JSON_PRETTY_PRINT')) }} -
-
-
- {% endif %} -
{{ '{oidc:client:owner}'|trans }}{{ client.owner }}
OpenID Federation related properties
{{ 'Is federated'|trans }}{{ (client.isFederated ? 'Yes' : 'No')|trans }}
{{ 'Entity Identifier'|trans }}{{ client.entityIdentifier }}
{{ 'Client registration types'|trans }} -
    - {% for clientRegistrationType in client.clientRegistrationTypes %} -
  • {{ clientRegistrationType }}
  • - {% endfor %} -
-
{{ 'Federation JWKS'|trans }} - {% if client.federationJwks %} -
-
-
- {{ client.federationJwks|json_encode(constant('JSON_PRETTY_PRINT')) }} -
-
-
- {% endif %} -
-
- - {{ '{oidc:return}'|trans }} - - - {{ '{oidc:edit}'|trans }} - - {% if client.isConfidential %} -
- {{ '{oidc:client:reset_secret}'|trans }} -
- +
+
+ + + + + + + + + + + + + + + + + + + {% if client.isConfidential %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + - -
+ {{ 'Name and description'|trans }} + + {{ client.name }}
+ {{ client.description }} +
+ {{ 'Type' }} + + {{ (client.isConfidential ? 'Confidential' : 'Public')|trans }} +
+ {{ 'Identifier'|trans }} + + {{ client.identifier }} +
+ {{ 'Secret'|trans }} + +
+ {{- client.secret -}} + + +
+
+ {{ 'Authentication Source'|trans }} + + {{ client.authSourceId|default('N/A'|trans) }} +
+ {{ 'Redirect URIs'|trans }} + +
    + {% for uri in client.redirectUri %} +
  • {{ uri }}
  • + {% endfor %} +
+
+ {{ 'Scopes'|trans }} + +
    + {% for key, scope in client.scopes %} +
  • {{ scope }}
  • + {% endfor %} +
+
+ {{ 'Back-channel Logout URI'|trans }} + + {{ client.backChannelLogoutUri|default('N/A') }} +
+ {{ 'Post-logout Redirect URIs'|trans }} + + {% if client.postLogoutRedirectUri is not empty %} +
    + {% for uri in client.postLogoutRedirectUri %} +
  • {{ uri }}
  • + {% endfor %} +
+ {% else %} + {{ 'N/A'|trans }} {% endif %} - - -
- -{% endblock %} - -{% block postload %} - {{ parent() }} - - -{% endblock %} +
+

{{ 'OpenID Federation Related Properties'|trans }}

+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ {{ 'Is Federated'|trans }} + + {{ (client.isFederated ? 'Yes' : 'No')|trans }} +
+ {{ 'Entity Identifier'|trans }} + + {{ client.entityIdentifier|default('N/A'|trans) }} +
+ {{ 'Client Registration Types'|trans }} + +
    + {% for clientRegistrationType in client.clientRegistrationTypes %} +
  • {{ clientRegistrationType }}
  • + {% endfor %} +
+
+ {{ 'Federation JWKS'|trans }} + + {% if client.federationJwks %} + + {{- client.federationJwks|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}} + + {% else %} + {{ 'N/A'|trans }} + {% endif %} +
+
+{% endblock oidcContent -%} diff --git a/tests/unit/src/Controller/Client/ShowControllerTest.php b/tests/unit/src/Controller/Client/ShowControllerTest.php index 52878ebd..1516fa1a 100644 --- a/tests/unit/src/Controller/Client/ShowControllerTest.php +++ b/tests/unit/src/Controller/Client/ShowControllerTest.php @@ -92,7 +92,7 @@ public function testItShowsClientDescription(): void ->expects($this->once()) ->method('build') ->with( - 'oidc:clients/show.twig', + 'oidc:clients/show-old.twig', [ 'client' => $this->clientEntityMock, 'allowedOrigins' => [], From 5eb4f8182cc59dc040d840f8f9973ae568b56844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Tue, 26 Nov 2024 12:16:24 +0100 Subject: [PATCH 09/17] WIP Move to SSP UI --- routing/routes/routes.php | 12 ++++- src/Codebooks/RoutesEnum.php | 3 ++ src/Controllers/Admin/ClientController.php | 52 ++++++++++++++++++- src/Controllers/Client/CreateController.php | 2 +- src/Factories/FormFactory.php | 4 +- src/Utils/Routes.php | 21 +++++++- templates/clients.twig | 31 ++++++----- .../clients/{_form.twig => _form-old.twig} | 0 templates/clients/new-old.twig | 15 ++++++ templates/clients/new.twig | 18 +++---- .../Client/CreateControllerTest.php | 2 +- 11 files changed, 126 insertions(+), 34 deletions(-) rename templates/clients/{_form.twig => _form-old.twig} (100%) create mode 100644 templates/clients/new-old.twig diff --git a/routing/routes/routes.php b/routing/routes/routes.php index 9c89c99c..2364cbd4 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -41,11 +41,21 @@ $routes->add(RoutesEnum::AdminClients->name, RoutesEnum::AdminClients->value) ->controller([ClientController::class, 'index']); + $routes->add(RoutesEnum::AdminClientsAdd->name, RoutesEnum::AdminClientsAdd->value) + ->controller([ClientController::class, 'add']) + ->methods([HttpMethodsEnum::GET->value]); + $routes->add(RoutesEnum::AdminClientsAddPersist->name, RoutesEnum::AdminClientsAddPersist->value) + ->controller([ClientController::class, 'addPersist']) + ->methods([HttpMethodsEnum::POST->value]); $routes->add(RoutesEnum::AdminClientsShow->name, RoutesEnum::AdminClientsShow->value) - ->controller([ClientController::class, 'show']); + ->controller([ClientController::class, 'show']) + ->methods([HttpMethodsEnum::GET->value]); $routes->add(RoutesEnum::AdminClientsResetSecret->name, RoutesEnum::AdminClientsResetSecret->value) ->controller([ClientController::class, 'resetSecret']) ->methods([HttpMethodsEnum::POST->value]); + $routes->add(RoutesEnum::AdminClientsDelete->name, RoutesEnum::AdminClientsDelete->value) + ->controller([ClientController::class, 'delete']) + ->methods([HttpMethodsEnum::POST->value]); /***************************************************************************************************************** * OpenID Connect diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index 88f0ec2f..233b6565 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -19,7 +19,10 @@ enum RoutesEnum: string case AdminClients = 'admin/clients'; case AdminClientsShow = 'admin/clients/show'; + case AdminClientsAdd = 'admin/clients/add'; + case AdminClientsAddPersist = 'admin/clients/add/persist'; case AdminClientsResetSecret = 'admin/clients/reset-secret'; + case AdminClientsDelete = 'admin/clients/delete'; /***************************************************************************************************************** * OpenID Connect diff --git a/src/Controllers/Admin/ClientController.php b/src/Controllers/Admin/ClientController.php index 49ba4117..c402c109 100644 --- a/src/Controllers/Admin/ClientController.php +++ b/src/Controllers/Admin/ClientController.php @@ -11,10 +11,13 @@ use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Exceptions\OidcException; +use SimpleSAML\Module\oidc\Factories\FormFactory; use SimpleSAML\Module\oidc\Factories\TemplateFactory; +use SimpleSAML\Module\oidc\Forms\ClientForm; use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Services\AuthContextService; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Services\SessionMessagesService; use SimpleSAML\Module\oidc\Utils\Routes; use Symfony\Component\HttpFoundation\Request; @@ -27,9 +30,11 @@ public function __construct( protected readonly Authorization $authorization, protected readonly ClientRepository $clientRepository, protected readonly AllowedOriginRepository $allowedOriginRepository, + protected readonly FormFactory $formFactory, protected readonly SspBridge $sspBridge, protected readonly SessionMessagesService $sessionMessagesService, protected readonly Routes $routes, + protected readonly LoggerService $logger, ) { $this->authorization->requireAdminOrUserWithPermission(AuthContextService::PERM_CLIENT); } @@ -98,7 +103,7 @@ public function resetSecret(Request $request): Response $oldSecret = $request->request->get('secret'); if ($oldSecret !== $client->getSecret()) { - throw new OidcException('Client secret does not match.'); + throw new OidcException('Client secret does not match on secret reset.'); } $client->restoreSecret($this->sspBridge->utils()->random()->generateID()); @@ -106,6 +111,7 @@ public function resetSecret(Request $request): Response $this->clientRepository->update($client, $authedUserId); $message = Translate::noop('Client secret has been reset.'); + $this->logger->info($message, $client->getState()); $this->sessionMessagesService->addMessage($message); return $this->routes->getRedirectResponseToModuleUrl( @@ -113,4 +119,48 @@ public function resetSecret(Request $request): Response [ParametersEnum::ClientId->value => $client->getIdentifier()], ); } + + /** + * @throws \SimpleSAML\Module\oidc\Exceptions\OidcException + */ + public function delete(Request $request): Response + { + $client = $this->getClientFromRequest($request); + + $secret = $request->request->get('secret'); + + if ($secret !== $client->getSecret()) { + throw new OidcException('Client secret does not match on delete.'); + } + + $authedUserId = $this->authorization->isAdmin() ? null : $this->authorization->getUserId(); + + $this->clientRepository->delete($client, $authedUserId); + + $message = Translate::noop('Client has been deleted.'); + $this->logger->warning($message, $client->getState()); + $this->sessionMessagesService->addMessage($message); + + return $this->routes->getRedirectResponseToModuleUrl( + RoutesEnum::AdminClients->value, + ); + } + + public function add(): Response + { + $form = $this->formFactory->build(ClientForm::class); + $form->setAction($this->routes->urlAdminClientsAddPersist()); + + return $this->templateFactory->build( + 'oidc:clients/new.twig', + [ + 'form' => $form, + 'regexUri' => ClientForm::REGEX_URI, + 'regexAllowedOriginUrl' => ClientForm::REGEX_ALLOWED_ORIGIN_URL, + 'regexHttpUri' => ClientForm::REGEX_HTTP_URI, + 'regexHttpUriPath' => ClientForm::REGEX_HTTP_URI_PATH, + ], + RoutesEnum::AdminClients->value, + ); + } } diff --git a/src/Controllers/Client/CreateController.php b/src/Controllers/Client/CreateController.php index a546d5d9..f962d00c 100644 --- a/src/Controllers/Client/CreateController.php +++ b/src/Controllers/Client/CreateController.php @@ -135,7 +135,7 @@ public function __invoke(): Template|RedirectResponse return new RedirectResponse((new HTTP())->addURLParameters('show.php', ['client_id' => $client['id']])); } - return $this->templateFactory->build('oidc:clients/new.twig', [ + return $this->templateFactory->build('oidc:clients/new-old.twig', [ 'form' => $form, 'regexUri' => ClientForm::REGEX_URI, 'regexAllowedOriginUrl' => ClientForm::REGEX_ALLOWED_ORIGIN_URL, diff --git a/src/Factories/FormFactory.php b/src/Factories/FormFactory.php index ebd4713b..4a164d74 100644 --- a/src/Factories/FormFactory.php +++ b/src/Factories/FormFactory.php @@ -31,10 +31,8 @@ public function __construct(private readonly ModuleConfig $moduleConfig, protect * @param class-string $classname Form classname * * @throws \SimpleSAML\Error\Exception - * - * @return mixed */ - public function build(string $classname): mixed + public function build(string $classname): Form { if (!is_a($classname, Form::class, true)) { throw new Exception("Invalid form: $classname"); diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index a5d18061..73a35163 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Utils; use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Codebooks\ParametersEnum; use SimpleSAML\Module\oidc\Codebooks\RoutesEnum; use SimpleSAML\Module\oidc\ModuleConfig; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -70,16 +71,32 @@ public function urlAdminClients(array $parameters = []): string public function urlAdminClientsShow(string $clientId, array $parameters = []): string { - $parameters['client_id'] = $clientId; + $parameters[ParametersEnum::ClientId->value] = $clientId; return $this->getModuleUrl(RoutesEnum::AdminClientsShow->value, $parameters); } + public function urlAdminClientsAdd(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::AdminClientsAdd->value, $parameters); + } + + public function urlAdminClientsAddPersist(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::AdminClientsAddPersist->value, $parameters); + } + public function urlAdminClientsResetSecret(string $clientId, array $parameters = []): string { - $parameters['client_id'] = $clientId; + $parameters[ParametersEnum::ClientId->value] = $clientId; return $this->getModuleUrl(RoutesEnum::AdminClientsResetSecret->value, $parameters); } + public function urlAdminClientsDelete(string $clientId, array $parameters = []): string + { + $parameters[ParametersEnum::ClientId->value] = $clientId; + return $this->getModuleUrl(RoutesEnum::AdminClientsDelete->value, $parameters); + } + /***************************************************************************************************************** * OpenID Connect ****************************************************************************************************************/ diff --git a/templates/clients.twig b/templates/clients.twig index 3c0753fe..39184c9d 100644 --- a/templates/clients.twig +++ b/templates/clients.twig @@ -22,7 +22,7 @@
- + {{ 'Add Client'|trans }} @@ -61,18 +61,23 @@
- - - - - - - - - +
+ + + + + + + + +
{% if allowedOrigins is not empty %} -