diff --git a/src/Bridges/SspBridge.php b/src/Bridges/SspBridge.php index 1d0a6173..6b5b7ffc 100644 --- a/src/Bridges/SspBridge.php +++ b/src/Bridges/SspBridge.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Module\oidc\Bridges; +use SimpleSAML\Module\oidc\Bridges\SspBridge\Auth; use SimpleSAML\Module\oidc\Bridges\SspBridge\Module; use SimpleSAML\Module\oidc\Bridges\SspBridge\Utils; @@ -13,6 +14,7 @@ */ class SspBridge { + protected static ?Auth $auth = null; protected static ?Utils $utils = null; protected static ?Module $module = null; @@ -25,4 +27,9 @@ public function module(): Module { return self::$module ??= new Module(); } + + public function auth(): Auth + { + return self::$auth ??= new Auth(); + } } diff --git a/src/Bridges/SspBridge/Auth.php b/src/Bridges/SspBridge/Auth.php new file mode 100644 index 00000000..d3be17d2 --- /dev/null +++ b/src/Bridges/SspBridge/Auth.php @@ -0,0 +1,17 @@ +buildForm(); @@ -315,6 +318,14 @@ public function setDefaults(object|array $data, bool $erase = false): static $data['jwks'] = is_array($data['jwks']) ? json_encode($data['jwks']) : null; + if ( + $data['auth_source'] !== null && + (!in_array($data['auth_source'], $this->sspBridge->auth()->source()->getSources())) + ) { + // Possible auth source name change without prior update in clients, resetting. + $data['auth_source'] = null; + } + parent::setDefaults($data, $erase); return $this; @@ -355,10 +366,9 @@ protected function buildForm(): void $this->addCheckbox('is_confidential', '{oidc:client:is_confidential}'); - // TODO mivanci Source::getSource() move to SSP Bridge. $this->addSelect('auth_source', '{oidc:client:auth_source}:') ->setHtmlAttribute('class', 'full-width') - ->setItems(Source::getSources(), false) + ->setItems($this->sspBridge->auth()->source()->getSources(), false) ->setPrompt(Translate::noop('-')); $scopes = $this->getScopes(); diff --git a/tests/config/authsources.php b/tests/config/authsources.php new file mode 100644 index 00000000..6d5b7481 --- /dev/null +++ b/tests/config/authsources.php @@ -0,0 +1,348 @@ + [ + // The default is to use core:AdminPassword, but it can be replaced with + // any authentication source. + + 'core:AdminPassword', + ], + + + // An authentication source which can authenticate against SAML 2.0 IdPs. + 'default-sp' => [ + 'saml:SP', + + // The entity ID of this SP. + 'entityID' => 'https://myapp.example.org/', + + // The entity ID of the IdP this SP should contact. + // Can be NULL/unset, in which case the user will be shown a list of available IdPs. + 'idp' => null, + + // The URL to the discovery service. + // Can be NULL/unset, in which case a builtin discovery service will be used. + 'discoURL' => null, + + /* + * If SP behind the SimpleSAMLphp in IdP/SP proxy mode requests + * AuthnContextClassRef, decide whether the AuthnContextClassRef will be + * processed by the IdP/SP proxy or if it will be passed to the original + * IdP in front of the IdP/SP proxy. + */ + 'proxymode.passAuthnContextClassRef' => false, + + /* + * The attributes parameter must contain an array of desired attributes by the SP. + * The attributes can be expressed as an array of names or as an associative array + * in the form of 'friendlyName' => 'name'. This feature requires 'name' to be set. + * The metadata will then be created as follows: + * + */ + /* + 'name' => [ + 'en' => 'A service', + 'no' => 'En tjeneste', + ], + + 'attributes' => [ + 'attrname' => 'urn:oid:x.x.x.x', + ], + 'attributes.required' => [ + 'urn:oid:x.x.x.x', + ], + */ + ], + + + /* + 'example-sql' => [ + 'sqlauth:SQL', + 'dsn' => 'pgsql:host=sql.example.org;port=5432;dbname=simplesaml', + 'username' => 'simplesaml', + 'password' => 'secretpassword', + 'query' => 'SELECT uid, givenName, email, eduPersonPrincipalName FROM users WHERE uid = :username ' . + 'AND password = SHA2(CONCAT((SELECT salt FROM users WHERE uid = :username), :password), 256);', + ], + */ + + /* + 'example-static' => [ + 'exampleauth:StaticSource', + 'uid' => ['testuser'], + 'eduPersonAffiliation' => ['member', 'employee'], + 'cn' => ['Test User'], + ], + */ + + /* + 'example-userpass' => [ + 'exampleauth:UserPass', + + // Give the user an option to save their username for future login attempts + // And when enabled, what should the default be, to save the username or not + //'remember.username.enabled' => false, + //'remember.username.checked' => false, + + 'users' => [ + 'student:studentpass' => [ + 'uid' => ['test'], + 'eduPersonAffiliation' => ['member', 'student'], + ], + 'employee:employeepass' => [ + 'uid' => ['employee'], + 'eduPersonAffiliation' => ['member', 'employee'], + ], + ], + ], + */ + + /* + 'crypto-hash' => [ + 'authcrypt:Hash', + // hashed version of 'verysecret', made with bin/pwgen.php + 'professor:{SSHA256}P6FDTEEIY2EnER9a6P2GwHhI5JDrwBgjQ913oVQjBngmCtrNBUMowA==' => [ + 'uid' => ['prof_a'], + 'eduPersonAffiliation' => ['member', 'employee', 'board'], + ], + ], + */ + + /* + 'htpasswd' => [ + 'authcrypt:Htpasswd', + 'htpasswd_file' => '/var/www/foo.edu/legacy_app/.htpasswd', + 'static_attributes' => [ + 'eduPersonAffiliation' => ['member', 'employee'], + 'Organization' => ['University of Foo'], + ], + ], + */ + + /* + // This authentication source serves as an example of integration with an + // external authentication engine. Take a look at the comment in the beginning + // of modules/exampleauth/lib/Auth/Source/External.php for a description of + // how to adjust it to your own site. + 'example-external' => [ + 'exampleauth:External', + ], + */ + + /* + 'yubikey' => [ + 'authYubiKey:YubiKey', + 'id' => '000', + // 'key' => '012345678', + ], + */ + + /* + 'facebook' => [ + 'authfacebook:Facebook', + // Register your Facebook application on http://www.facebook.com/developers + // App ID or API key (requests with App ID should be faster; https://github.com/facebook/php-sdk/issues/214) + 'api_key' => 'xxxxxxxxxxxxxxxx', + // App Secret + 'secret' => 'xxxxxxxxxxxxxxxx', + // which additional data permissions to request from user + // see http://developers.facebook.com/docs/authentication/permissions/ for the full list + // 'req_perms' => 'email,user_birthday', + // Which additional user profile fields to request. + // When empty, only the app-specific user id and name will be returned + // See https://developers.facebook.com/docs/graph-api/reference/v2.6/user for the full list + // 'user_fields' => 'email,birthday,third_party_id,name,first_name,last_name', + ], + */ + + /* + // Twitter OAuth Authentication API. + // Register your application to get an API key here: + // http://twitter.com/oauth_clients + 'twitter' => [ + 'authtwitter:Twitter', + 'key' => 'xxxxxxxxxxxxxxxx', + 'secret' => 'xxxxxxxxxxxxxxxx', + // Forces the user to enter their credentials to ensure the correct users account is authorized. + // Details: https://dev.twitter.com/docs/api/1/get/oauth/authenticate + 'force_login' => false, + ], + */ + + /* + // Microsoft Account (Windows Live ID) Authentication API. + // Register your application to get an API key here: + // https://apps.dev.microsoft.com/ + 'windowslive' => [ + 'authwindowslive:LiveID', + 'key' => 'xxxxxxxxxxxxxxxx', + 'secret' => 'xxxxxxxxxxxxxxxx', + ], + */ + + /* + // Example of a LDAP authentication source. + 'example-ldap' => [ + 'ldap:Ldap', + + // The connection string for the LDAP-server. + // You can add multiple by separating them with a space. + 'connection_string' => 'ldap.example.org', + + // Whether SSL/TLS should be used when contacting the LDAP server. + // Possible values are 'ssl', 'tls' or 'none' + 'encryption' => 'ssl', + + // The LDAP version to use when interfacing the LDAP-server. + // Defaults to 3 + 'version' => 3, + + // Set to TRUE to enable LDAP debug level. Passed to the LDAP connector class. + // + // Default: FALSE + // Required: No + 'ldap.debug' => false, + + // The LDAP-options to pass when setting up a connection + // See [Symfony documentation][1] + 'options' => [ + + // Set whether to follow referrals. + // AD Controllers may require 0x00 to function. + // Possible values are 0x00 (NEVER), 0x01 (SEARCHING), + // 0x02 (FINDING) or 0x03 (ALWAYS). + 'referrals' => 0x00, + + 'network_timeout' => 3, + ], + + // The connector to use. + // Defaults to '\SimpleSAML\Module\ldap\Connector\Ldap', but can be set + // to '\SimpleSAML\Module\ldap\Connector\ActiveDirectory' when + // authenticating against Microsoft Active Directory. This will + // provide you with more specific error messages. + 'connector' => '\SimpleSAML\Module\ldap\Connector\Ldap', + + // Which attributes should be retrieved from the LDAP server. + // This can be an array of attribute names, or NULL, in which case + // all attributes are fetched. + 'attributes' => null, + + // Which attributes should be base64 encoded after retrieval from + // the LDAP server. + 'attributes.binary' => [ + 'jpegPhoto', + 'objectGUID', + 'objectSid', + 'mS-DS-ConsistencyGuid' + ], + + // The pattern which should be used to create the user's DN given + // the username. %username% in this pattern will be replaced with + // the user's username. + // + // This option is not used if the search.enable option is set to TRUE. + 'dnpattern' => 'uid=%username%,ou=people,dc=example,dc=org', + + // As an alternative to specifying a pattern for the users DN, it is + // possible to search for the username in a set of attributes. This is + // enabled by this option. + 'search.enable' => false, + + // An array on DNs which will be used as a base for the search. In + // case of multiple strings, they will be searched in the order given. + 'search.base' => [ + 'ou=people,dc=example,dc=org', + ], + + // The scope of the search. Valid values are 'sub' and 'one' and + // 'base', first one being the default if no value is set. + 'search.scope' => 'sub', + + // The attribute(s) the username should match against. + // + // This is an array with one or more attribute names. Any of the + // attributes in the array may match the value the username. + 'search.attributes' => ['uid', 'mail'], + + // Additional filters that must match for the entire LDAP search to + // be true. + // + // This should be a single string conforming to [RFC 1960][2] + // and [RFC 2544][3]. The string is appended to the search attributes + 'search.filter' => '(&(objectClass=Person)(|(sn=Doe)(cn=John *)))', + + // The username & password where SimpleSAMLphp should bind to before + // searching. If this is left NULL, no bind will be performed before + // searching. + 'search.username' => null, + 'search.password' => null, + ], + */ + + /* + // Example of an LDAPMulti authentication source. + 'example-ldapmulti' => [ + 'ldap:LdapMulti', + + // The way the organization as part of the username should be handled. + // Three possible values: + // - 'none': No handling of the organization. Allows '@' to be part + // of the username. + // - 'allow': Will allow users to type 'username@organization'. + // - 'force': Force users to type 'username@organization'. The dropdown + // list will be hidden. + // + // The default is 'none'. + 'username_organization_method' => 'none', + + // Whether the organization should be included as part of the username + // when authenticating. If this is set to TRUE, the username will be on + // the form @. If this is FALSE, the + // username will be used as the user enters it. + // + // The default is FALSE. + 'include_organization_in_username' => false, + + // A list of available LDAP servers. + // + // The index is an identifier for the organization/group. When + // 'username_organization_method' is set to something other than 'none', + // the organization-part of the username is matched against the index. + // + // The value of each element is an array in the same format as an LDAP + // authentication source. + 'mapping' => [ + 'employees' => [ + // A short name/description for this group. Will be shown in a + // dropdown list when the user logs on. + // + // This option can be a string or an array with + // language => text mappings. + 'description' => 'Employees', + 'authsource' => 'example-ldap', + ], + + 'students' => [ + 'description' => 'Students', + 'authsource' => 'example-ldap-2', + ], + ], + ], + */ +]; diff --git a/tests/unit/src/Bridges/SspBridge/Auth/SourceTest.php b/tests/unit/src/Bridges/SspBridge/Auth/SourceTest.php new file mode 100644 index 00000000..63c0989d --- /dev/null +++ b/tests/unit/src/Bridges/SspBridge/Auth/SourceTest.php @@ -0,0 +1,23 @@ +assertTrue(in_array('admin', $this->sut()->getSources())); + } +} diff --git a/tests/unit/src/Bridges/SspBridge/AuthTest.php b/tests/unit/src/Bridges/SspBridge/AuthTest.php new file mode 100644 index 00000000..d573f64b --- /dev/null +++ b/tests/unit/src/Bridges/SspBridge/AuthTest.php @@ -0,0 +1,28 @@ +assertInstanceOf(Auth::class, $this->sut()); + } + + public function testCanBuildSourceInstance(): void + { + $this->assertInstanceOf(Auth\Source::class, $this->sut()->source()); + } +} diff --git a/tests/unit/src/Bridges/SspBridgeTest.php b/tests/unit/src/Bridges/SspBridgeTest.php index bd4da0aa..12ab86a5 100644 --- a/tests/unit/src/Bridges/SspBridgeTest.php +++ b/tests/unit/src/Bridges/SspBridgeTest.php @@ -30,4 +30,9 @@ public function testCanBuildModuleInstance(): void { $this->assertInstanceOf(SspBridge\Module::class, $this->sut()->module()); } + + public function testCanBuildAuthInstance(): void + { + $this->assertInstanceOf(SspBridge\Auth::class, $this->sut()->auth()); + } } diff --git a/tests/unit/src/Forms/ClientFormTest.php b/tests/unit/src/Forms/ClientFormTest.php index 59700f2e..fbe200dc 100644 --- a/tests/unit/src/Forms/ClientFormTest.php +++ b/tests/unit/src/Forms/ClientFormTest.php @@ -4,12 +4,14 @@ namespace SimpleSAML\Test\Module\oidc\unit\Forms; +use DateTimeImmutable; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use SimpleSAML\Configuration; +use SimpleSAML\Module\oidc\Bridges\SspBridge; +use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; use SimpleSAML\Module\oidc\Forms\ClientForm; use SimpleSAML\Module\oidc\Forms\Controls\CsrfProtection; use SimpleSAML\Module\oidc\ModuleConfig; @@ -19,14 +21,16 @@ */ class ClientFormTest extends TestCase { - /** @var \PHPUnit\Framework\MockObject\MockObject */ - protected MockObject $csrfProtection; + protected MockObject $csrfProtectionMock; - /** @var \PHPUnit\Framework\MockObject\MockObject */ - protected MockObject $moduleConfig; + protected MockObject $moduleConfigMock; - /** @var \PHPUnit\Framework\MockObject\MockObject */ protected MockObject $serverRequestMock; + protected MockObject $sspBridgeMock; + protected MockObject $sspBridgeAuthMock; + protected MockObject $sspBridgeAuthSourceMock; + + protected array $clientDataSample; /** * @throws \Exception @@ -34,10 +38,63 @@ class ClientFormTest extends TestCase public function setUp(): void { parent::setUp(); - Configuration::clearInternalState(); - $this->csrfProtection = $this->createMock(CsrfProtection::class); - $this->moduleConfig = $this->createMock(ModuleConfig::class); + $this->csrfProtectionMock = $this->createMock(CsrfProtection::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); $this->serverRequestMock = $this->createMock(ServerRequest::class); + $this->sspBridgeMock = $this->createMock(SspBridge::class); + + $this->sspBridgeAuthMock = $this->createMock(SspBridge\Auth::class); + $this->sspBridgeMock->method('auth')->willReturn($this->sspBridgeAuthMock); + + $this->sspBridgeAuthSourceMock = $this->createMock(SspBridge\Auth\Source::class); + $this->sspBridgeAuthMock->method('source')->willReturn($this->sspBridgeAuthSourceMock); + + $this->clientDataSample = [ + 'id' => 'clientId', + 'secret' => 'clientSecret', + 'name' => 'Test', + 'description' => 'Test', + 'auth_source' => 'default-sp', + 'redirect_uri' => [0 => 'https://example.com/redirect',], + 'scopes' => [0 => 'openid', 1 => 'offline_access', 2 => 'profile',], + 'is_enabled' => false, + 'is_confidential' => true, + 'owner' => null, + 'post_logout_redirect_uri' => [0 => 'https://example.com/',], + 'backchannel_logout_uri' => 'https://example.com/logout', + 'entity_identifier' => 'https://example.com/', + 'client_registration_types' => [0 => 'automatic',], + 'federation_jwks' => ['keys' => [0 => [],],], + 'jwks' => ['keys' => [0 => [],],], + 'jwks_uri' => 'https://example.com/jwks', + 'signed_jwks_uri' => 'https://example.com/signed-jwks', + 'registration_type' => RegistrationTypeEnum::Manual, + 'updated_at' => DateTimeImmutable::__set_state( + ['date' => '2025-02-05 15:05:27.000000', 'timezone_type' => 3, 'timezone' => 'UTC',], + ), + 'created_at' => DateTimeImmutable::__set_state( + ['date' => '2024-12-01 11:54:12.000000', 'timezone_type' => 3, 'timezone' => 'UTC',], + ), + 'expires_at' => null, + 'is_federated' => false, + 'allowed_origin' => [], + ]; + } + + protected function sut( + ?ModuleConfig $moduleConfig = null, + ?CsrfProtection $csrfProtection = null, + ?SspBridge $sspBridge = null, + ): ClientForm { + $moduleConfig ??= $this->moduleConfigMock; + $csrfProtection ??= $this->csrfProtectionMock; + $sspBridge ??= $this->sspBridgeMock; + + return new ClientForm( + $moduleConfig, + $csrfProtection, + $sspBridge, + ); } public static function validateOriginProvider(): array @@ -74,7 +131,6 @@ public static function validateOriginProvider(): array ]; } - /** * @param string $url * @param bool $isValid @@ -86,19 +142,26 @@ public static function validateOriginProvider(): array #[TestDox('Allowed Origin URL: $url is expected to be $isValid')] public function testValidateOrigin(string $url, bool $isValid): void { - $clientForm = $this->prepareMockedInstance(); + $clientForm = $this->sut(); $clientForm->setValues(['allowed_origin' => $url]); $clientForm->validateAllowedOrigin($clientForm); $this->assertEquals(!$isValid, $clientForm->hasErrors(), $url); } - /** - * @return \SimpleSAML\Module\oidc\Forms\ClientForm - * @throws \Exception - */ - protected function prepareMockedInstance(): ClientForm + public function testSetDefaultsLeavesValidAuthSourceValue(): void { - return new ClientForm($this->moduleConfig, $this->csrfProtection); + $this->sspBridgeAuthSourceMock->method('getSources')->willReturn(['default-sp']); + + $sut = $this->sut()->setDefaults($this->clientDataSample); + + $this->assertSame('default-sp', $sut->getValues()['auth_source']); + } + + public function testSetDefaultsUnsetsAuthSourceIfNotValid(): void + { + $sut = $this->sut()->setDefaults($this->clientDataSample); + + $this->assertNull($sut->getValues()['auth_source']); } }