Skip to content

Commit dbb8a65

Browse files
committed
feat: Implement IAlternativeLoginProvider
Signed-off-by: Carl Schwan <carlschwan@kde.org>
1 parent d5cb6d0 commit dbb8a65

9 files changed

Lines changed: 247 additions & 74 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\User_SAML\AlternativeLogin;
11+
12+
use OCP\Authentication\IAlternativeLogin;
13+
14+
class AlternativeLogin implements IAlternativeLogin {
15+
public function __construct(
16+
private readonly string $name,
17+
private readonly string $href,
18+
) {
19+
}
20+
21+
#[\Override]
22+
public function getLabel(): string {
23+
return $this->name;
24+
}
25+
26+
#[\Override]
27+
public function getLink(): string {
28+
return $this->href;
29+
}
30+
31+
#[\Override]
32+
public function getClass(): string {
33+
return '';
34+
}
35+
36+
#[\Override]
37+
public function load(): void {
38+
}
39+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\User_SAML\AlternativeLogin;
11+
12+
use OCA\User_SAML\Service\ProviderListingService;
13+
use OCP\AppFramework\Services\IAppConfig;
14+
use OCP\Authentication\IAlternativeLoginProvider;
15+
use OCP\IRequest;
16+
use OCP\IURLGenerator;
17+
18+
/**
19+
* @psalm-suppress UndefinedClass IAlternativeLoginProvider is only defined in NC >= 34
20+
*/
21+
class AlternativeLoginProvider implements IAlternativeLoginProvider {
22+
public function __construct(
23+
private readonly IRequest $request,
24+
private readonly IUrlGenerator $urlGenerator,
25+
private readonly ProviderListingService $providerListingService,
26+
private readonly IAppConfig $appConfig,
27+
) {
28+
}
29+
30+
#[\Override]
31+
public function getAlternativeLogins(): array {
32+
$type = $this->appConfig->getAppValueString('type');
33+
34+
if ($type !== 'saml') {
35+
return [];
36+
}
37+
38+
$redirectUrl = $this->request->getParam('redirect_url') ?? '';
39+
$absoluteRedirectUrl = $this->urlGenerator->getAbsoluteURL($redirectUrl);
40+
return array_map(fn (array $idp): AlternativeLogin
41+
=> new AlternativeLogin($idp['display-name'], $idp['url']), $this->providerListingService->getIdps($absoluteRedirectUrl));
42+
}
43+
}

lib/AppInfo/Application.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use OC\User\LoginException;
1515
use OC_User;
1616
use OCA\DAV\Events\SabrePluginAddEvent;
17+
use OCA\User_SAML\AlternativeLogin\AlternativeLoginProvider;
1718
use OCA\User_SAML\DavPlugin;
1819
use OCA\User_SAML\GroupBackend;
1920
use OCA\User_SAML\Listener\CookieLoginEventListener;
@@ -68,6 +69,14 @@ public function register(IRegistrationContext $context): void {
6869
$c->get(SAMLSettings::class),
6970
$c->get(SessionService::class),
7071
));
72+
73+
if (method_exists($context, 'registerAlternativeLoginProvider')) {
74+
/**
75+
* @psalm-suppress UndefinedInterfaceMethod
76+
* @psalm-suppress MissingDependency
77+
*/
78+
$context->registerAlternativeLoginProvider(AlternativeLoginProvider::class);
79+
}
7180
}
7281

7382
#[\Override]

lib/Controller/SAMLController.php

Lines changed: 3 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
use Firebase\JWT\Key;
1313
use OC\Core\Controller\ClientFlowLoginController;
1414
use OC\Core\Controller\ClientFlowLoginV2Controller;
15-
use OC\Security\CSRF\CsrfTokenManager;
1615
use OCA\User_SAML\Attributes\OnlyUnauthenticatedUsers;
1716
use OCA\User_SAML\Exceptions\NoUserFoundException;
1817
use OCA\User_SAML\Exceptions\UserFilterViolationException;
1918
use OCA\User_SAML\Helper\TXmlHelper;
2019
use OCA\User_SAML\SAMLSettings;
20+
use OCA\User_SAML\Service\ProviderListingService;
2121
use OCA\User_SAML\Service\SessionService;
2222
use OCA\User_SAML\UserBackend;
2323
use OCA\User_SAML\UserData;
@@ -42,8 +42,6 @@
4242
use OneLogin\Saml2\Error;
4343
use OneLogin\Saml2\Settings;
4444
use OneLogin\Saml2\ValidationError;
45-
use Psr\Container\ContainerExceptionInterface;
46-
use Psr\Container\NotFoundExceptionInterface;
4745
use Psr\Log\LoggerInterface;
4846

4947
class SAMLController extends Controller {
@@ -66,6 +64,7 @@ public function __construct(
6664
private ICrypto $crypto,
6765
private ITrustedDomainHelper $trustedDomainHelper,
6866
private SessionService $sessionService,
67+
private ProviderListingService $providerListingService,
6968
) {
7069
parent::__construct($appName, $request);
7170
}
@@ -591,68 +590,12 @@ public function selectUserBackEnd(string $redirectUrl = ''): Http\TemplateRespon
591590
];
592591
}
593592

594-
$attributes['loginUrls']['ssoLogin'] = $this->getIdps($redirectUrl);
593+
$attributes['loginUrls']['ssoLogin'] = $this->providerListingService->getIdps($redirectUrl);
595594
$attributes['useCombobox'] = count($attributes['loginUrls']['ssoLogin']) > 4;
596595

597596
return new Http\TemplateResponse($this->appName, 'selectUserBackEnd', $attributes, 'guest');
598597
}
599598

600-
/**
601-
* get the IdPs showed at the login page
602-
*/
603-
private function getIdps(string $redirectUrl): array {
604-
$result = [];
605-
$idps = $this->samlSettings->getListOfIdps();
606-
foreach ($idps as $idpId => $displayName) {
607-
$result[] = [
608-
'url' => $this->getSSOUrl($redirectUrl, (string)$idpId),
609-
'display-name' => $this->getSSODisplayName($displayName),
610-
];
611-
}
612-
613-
return $result;
614-
}
615-
616-
/**
617-
* @throws ContainerExceptionInterface
618-
* @throws NotFoundExceptionInterface
619-
* @throws \OCP\DB\Exception
620-
*/
621-
private function getSSOUrl(string $redirectUrl, string $idp): string {
622-
$originalUrl = '';
623-
if (!empty($redirectUrl)) {
624-
$originalUrl = $this->urlGenerator->getAbsoluteURL($redirectUrl);
625-
}
626-
627-
/** @var CsrfTokenManager $csrfTokenManager */
628-
$csrfTokenManager = Server::get(CsrfTokenManager::class);
629-
$csrfToken = $csrfTokenManager->getToken();
630-
631-
$settings = $this->samlSettings->get((int)$idp);
632-
$method = $settings['general-is_saml_request_using_post'] ?? 'get';
633-
634-
return $this->urlGenerator->linkToRouteAbsolute(
635-
'user_saml.SAML.login',
636-
[
637-
'requesttoken' => $csrfToken->getEncryptedValue(),
638-
'originalUrl' => $originalUrl,
639-
'idp' => $idp,
640-
'method' => $method,
641-
]
642-
);
643-
}
644-
645-
/**
646-
* Return the display name of the SSO identity provider.
647-
*/
648-
protected function getSSODisplayName(?string $displayName): string {
649-
if ($displayName === null || $displayName === '') {
650-
$displayName = $this->l->t('SSO & SAML log in');
651-
}
652-
653-
return $displayName;
654-
}
655-
656599
/**
657600
* get Nextcloud login URL
658601
*/
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\User_SAML\Service;
11+
12+
use OC\Security\CSRF\CsrfTokenManager;
13+
use OCA\User_SAML\SAMLSettings;
14+
use OCP\IL10N;
15+
use OCP\IURLGenerator;
16+
use Psr\Container\ContainerExceptionInterface;
17+
use Psr\Container\NotFoundExceptionInterface;
18+
19+
class ProviderListingService {
20+
public function __construct(
21+
private readonly IL10N $l10n,
22+
private readonly IUrlGenerator $urlGenerator,
23+
private readonly SAMLSettings $samlSettings,
24+
private readonly CsrfTokenManager $csrfTokenManager,
25+
) {
26+
}
27+
28+
/**
29+
* Return the display name of the SSO identity provider.
30+
*/
31+
protected function getSSODisplayName(?string $displayName): string {
32+
if ($displayName === null || $displayName === '') {
33+
$displayName = $this->l10n->t('SSO & SAML log in');
34+
}
35+
36+
return $displayName;
37+
}
38+
39+
/**
40+
* @throws ContainerExceptionInterface
41+
* @throws NotFoundExceptionInterface
42+
* @throws \OCP\DB\Exception
43+
*/
44+
private function getSSOUrl(string $redirectUrl, int $idp): string {
45+
$originalUrl = '';
46+
if (!empty($redirectUrl)) {
47+
$originalUrl = $this->urlGenerator->getAbsoluteURL($redirectUrl);
48+
}
49+
50+
$csrfToken = $this->csrfTokenManager->getToken();
51+
52+
$settings = $this->samlSettings->get($idp);
53+
$method = $settings['general-is_saml_request_using_post'] ?? 'get';
54+
55+
return $this->urlGenerator->linkToRouteAbsolute(
56+
'user_saml.SAML.login',
57+
[
58+
'requesttoken' => $csrfToken->getEncryptedValue(),
59+
'originalUrl' => $originalUrl,
60+
'idp' => (string)$idp,
61+
'method' => $method,
62+
]
63+
);
64+
}
65+
66+
/**
67+
* Get the IdPs showed at the login page
68+
*
69+
* @return list<array{url: string, display-name: string}>
70+
*/
71+
public function getIdps(string $redirectUrl): array {
72+
$result = [];
73+
$idps = $this->samlSettings->getListOfIdps();
74+
foreach ($idps as $idpId => $displayName) {
75+
$result[] = [
76+
'url' => $this->getSSOUrl($redirectUrl, $idpId),
77+
'display-name' => $this->getSSODisplayName($displayName),
78+
];
79+
}
80+
81+
return $result;
82+
}
83+
}

psalm.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
xmlns="https://getpsalm.org/schema/config"
1010
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
1111
errorBaseline="tests/psalm-baseline.xml"
12-
findUnusedBaselineEntry="true"
12+
findUnusedBaselineEntry="false"
1313
findUnusedCode="false"
1414
phpVersion="8.1"
1515
>

tests/psalm-baseline.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
- SPDX-License-Identifier: AGPL-3.0-or-later
55
-->
66
<files psalm-version="6.16.1@f1f5de594dc76faf8784e02d3dc4716c91c6f6ac">
7+
<file src="lib/AlternativeLogin/AlternativeLoginProvider.php">
8+
<UndefinedClass>
9+
<code><![CDATA[IAlternativeLoginProvider]]></code>
10+
</UndefinedClass>
11+
</file>
712
<file src="lib/UserBackend.php">
813
<DeprecatedInterface>
914
<code><![CDATA[UserBackend]]></code>

tests/unit/Controller/SAMLControllerTest.php

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use OCA\User_SAML\Exceptions\NoUserFoundException;
1313
use OCA\User_SAML\Exceptions\UserFilterViolationException;
1414
use OCA\User_SAML\SAMLSettings;
15+
use OCA\User_SAML\Service\ProviderListingService;
1516
use OCA\User_SAML\Service\SessionService;
1617
use OCA\User_SAML\UserBackend;
1718
use OCA\User_SAML\UserData;
@@ -51,6 +52,7 @@ class SAMLControllerTest extends TestCase {
5152
private SAMLController $samlController;
5253
private ITrustedDomainHelper|MockObject $trustedDomainController;
5354
private SessionService|MockObject $sessionService;
55+
private ProviderListingService|MockObject $providerListingService;
5456

5557
#[Override]
5658
protected function setUp(): void {
@@ -71,6 +73,7 @@ protected function setUp(): void {
7173
$this->crypto = $this->createMock(ICrypto::class);
7274
$this->trustedDomainController = $this->createMock(ITrustedDomainHelper::class);
7375
$this->sessionService = $this->createMock(SessionService::class);
76+
$this->providerListingService = $this->createMock(ProviderListingService::class);
7477

7578
$this->l->expects($this->any())->method('t')->willReturnCallback(
7679
static fn (string $param): string => $param
@@ -95,7 +98,8 @@ protected function setUp(): void {
9598
$this->userData,
9699
$this->crypto,
97100
$this->trustedDomainController,
98-
$this->sessionService
101+
$this->sessionService,
102+
$this->providerListingService,
99103
);
100104
}
101105

@@ -331,18 +335,6 @@ public static function dataTestGenericError(): \Generator {
331335
yield ['messageSend' => 'authFailed', 'messageExpected' => 'Authentication failed.'];
332336
}
333337

334-
#[DataProvider('dataTestGetSSODisplayName')]
335-
public function testGetSSODisplayName(string $configuredDisplayName, string $expected): void {
336-
$result = $this->invokePrivate($this->samlController, 'getSSODisplayName', [$configuredDisplayName]);
337-
338-
$this->assertSame($expected, $result);
339-
}
340-
341-
public static function dataTestGetSSODisplayName(): \Generator {
342-
yield ['My identity provider', 'My identity provider'];
343-
yield ['', 'SSO & SAML log in'];
344-
}
345-
346338
public static function userFilterDataProvider(): array {
347339
return [
348340
[ // 0 - test rejection by membership

0 commit comments

Comments
 (0)