Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,13 @@ public function boot(IBootContext $context): void {
}

$multipleUserBackEnds = $samlSettings->allowMultipleUserBackEnds();
$configuredIdps = $samlSettings->getListOfIdps();
$configuredIdps = $samlSettings->getListOfConfiguredIdps();
// If no IdP has the minimum required config (entityId + SSO URL), fall through to normal login
// only for regular SAML mode. Environment-variable mode can still redirect to SAMLController::login()
// without requiring configured IdP metadata.
if ($type === 'saml' && empty($configuredIdps)) {
return;
}
Comment on lines +179 to +185
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new login-flow behavior (falling through to core login when SAML is enabled but no IdP is minimally configured) is security/availability sensitive and currently isn’t covered by automated tests. Please add a unit/integration test that asserts /login is redirected to the SAML endpoint when an IdP is configured, and is not redirected when no IdP has entityId+SSO URL.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added unit tests for SAMLSettings::getListOfConfiguredIdps() in tests/unit/SAMLSettingsTest.php (commit 6c8ed9c). The tests cover:

  • No configurations → empty array (login falls through to Nextcloud)
  • IdP with both idp-entityId + idp-singleSignOnService.url → returned (SAML redirect applies)
  • IdP with whitespace-only values → filtered out (login falls through)
  • Mix of fully and partially configured IdPs → only fully configured ones returned

Since Application::boot() uses header() + exit() which can't be unit-tested in isolation, the tests are placed on getListOfConfiguredIdps() — the method that drives the redirect decision.

$showLoginOptions = $type !== 'environment-variable' && ($multipleUserBackEnds || count($configuredIdps) > 1);

if ($redirectSituation === true && $showLoginOptions) {
Expand Down
22 changes: 22 additions & 0 deletions lib/SAMLSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,28 @@ public function getListOfIdps(): array {
return $result;
}

/**
* Get list of IDPs that have the minimum required configuration (entityId + SSO URL).
* Used to avoid blocking login when SAML is enabled but not yet configured.
*
* @return array<int, string>
* @throws Exception
*/
public function getListOfConfiguredIdps(): array {
$this->ensureConfigurationsLoaded();

$result = [];
foreach ($this->configurations as $configID => $config) {
$entityId = trim((string)($config['idp-entityId'] ?? ''));
$ssoUrl = trim((string)($config['idp-singleSignOnService.url'] ?? ''));
if ($entityId !== '' && $ssoUrl !== '') {
$result[$configID] = $config['general-idp0_display_name'] ?? '';
}
}

return $result;
}

/**
* Check if multiple user back ends are allowed
*/
Expand Down
141 changes: 141 additions & 0 deletions tests/unit/SAMLSettingsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\User_SAML\Tests;

use OCA\User_SAML\Db\ConfigurationsMapper;
use OCA\User_SAML\SAMLSettings;
use OCP\IConfig;
use OCP\ISession;
use OCP\IURLGenerator;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;

class SAMLSettingsTest extends TestCase {
private IURLGenerator&MockObject $urlGenerator;
private IConfig&MockObject $config;
private ISession&MockObject $session;
private ConfigurationsMapper&MockObject $mapper;
private SAMLSettings $samlSettings;

protected function setUp(): void {
parent::setUp();

$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->config = $this->createMock(IConfig::class);
$this->session = $this->createMock(ISession::class);
$this->mapper = $this->createMock(ConfigurationsMapper::class);

$this->samlSettings = new SAMLSettings(
$this->urlGenerator,
$this->config,
$this->session,
$this->mapper,
);
}

public function testGetListOfConfiguredIdpsReturnsEmptyWhenNoIdpsExist(): void {
$this->mapper->expects($this->once())
->method('getAll')
->willReturn([]);

$result = $this->samlSettings->getListOfConfiguredIdps();

$this->assertSame([], $result);
}

public function testGetListOfConfiguredIdpsReturnsEmptyWhenEntityIdAndSsoUrlMissing(): void {
$this->mapper->expects($this->once())
->method('getAll')
->willReturn([
1 => [
'general-idp0_display_name' => 'My IdP',
// no idp-entityId, no idp-singleSignOnService.url
],
]);

$result = $this->samlSettings->getListOfConfiguredIdps();

$this->assertSame([], $result);
}

public function testGetListOfConfiguredIdpsReturnsEmptyWhenValuesAreOnlyWhitespace(): void {
$this->mapper->expects($this->once())
->method('getAll')
->willReturn([
1 => [
'general-idp0_display_name' => 'My IdP',
'idp-entityId' => ' ',
'idp-singleSignOnService.url' => "\t",
],
]);

$result = $this->samlSettings->getListOfConfiguredIdps();

$this->assertSame([], $result);
}

public function testGetListOfConfiguredIdpsReturnsIdpWhenFullyConfigured(): void {
$this->mapper->expects($this->once())
->method('getAll')
->willReturn([
1 => [
'general-idp0_display_name' => 'My IdP',
'idp-entityId' => 'https://idp.example.com',
'idp-singleSignOnService.url' => 'https://idp.example.com/sso',
],
]);

$result = $this->samlSettings->getListOfConfiguredIdps();

$this->assertSame([1 => 'My IdP'], $result);
}

public function testGetListOfConfiguredIdpsFiltersOutPartiallyConfiguredIdps(): void {
$this->mapper->expects($this->once())
->method('getAll')
->willReturn([
1 => [
'general-idp0_display_name' => 'Configured IdP',
'idp-entityId' => 'https://idp.example.com',
'idp-singleSignOnService.url' => 'https://idp.example.com/sso',
],
2 => [
'general-idp0_display_name' => 'Missing SSO URL',
'idp-entityId' => 'https://idp2.example.com',
// missing idp-singleSignOnService.url
],
3 => [
'general-idp0_display_name' => 'Missing Entity ID',
// missing idp-entityId
'idp-singleSignOnService.url' => 'https://idp3.example.com/sso',
],
]);

$result = $this->samlSettings->getListOfConfiguredIdps();

$this->assertSame([1 => 'Configured IdP'], $result);
}

public function testGetListOfConfiguredIdpsUsesDisplayNameAsValue(): void {
$this->mapper->expects($this->once())
->method('getAll')
->willReturn([
1 => [
'idp-entityId' => 'https://idp.example.com',
'idp-singleSignOnService.url' => 'https://idp.example.com/sso',
// no display name set
],
]);

$result = $this->samlSettings->getListOfConfiguredIdps();

$this->assertSame([1 => ''], $result);
}
}