From ac31e10ce576f7fe0cf4c138a563291060f97c53 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Mon, 8 Jun 2026 07:46:51 -0400 Subject: [PATCH] Add support for EXTERNAL SASL auth. --- .gitignore | 5 +- composer.json | 4 +- docs/Server/Configuration.md | 43 +++ src/FreeDSx/Ldap/ClientOptions.php | 34 +++ src/FreeDSx/Ldap/Container.php | 2 + .../Ldap/Protocol/Authorization/AuthzId.php | 98 +++++++ .../Authorization/AuthzIdResolver.php | 167 +++++++++++ .../Protocol/Authorization/AuthzIdType.php | 28 ++ .../ProxiedAuthorizationResolver.php | 158 +---------- .../ExternalMechanismOptionsBuilder.php | 172 ++++++++++++ .../MechanismOptionsBuilderFactory.php | 11 +- .../MechanismOptionsBuilderInterface.php | 10 + .../OptionsBuilder/RequireIdentityTrait.php | 10 + .../Ldap/Protocol/Bind/Sasl/SaslExchange.php | 3 +- .../Protocol/Bind/Sasl/SaslExchangeResult.php | 6 + src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php | 1 + src/FreeDSx/Ldap/Protocol/LdapQueue.php | 6 + .../ExternalCredentialMapperInterface.php | 30 ++ .../External/SubjectDnCredentialMapper.php | 72 +++++ .../Ldap/Server/ServerProtocolFactory.php | 53 +++- src/FreeDSx/Ldap/ServerOptions.php | 21 ++ .../Security/LdapExternalSaslServerTest.php | 149 ++++++++++ tests/resources/cert/test-cases/ext-ca.crt | 19 ++ .../resources/cert/test-cases/ext-client.crt | 20 ++ .../resources/cert/test-cases/ext-client.key | 28 ++ .../resources/cert/test-cases/ext-nobody.crt | 20 ++ .../resources/cert/test-cases/ext-nobody.key | 28 ++ .../test-cases/generate-external-certs.sh | 33 +++ tests/support/LdapServerCommand.php | 28 ++ .../Authorization/AuthzIdResolverTest.php | 216 +++++++++++++++ .../Protocol/Authorization/AuthzIdTest.php | 101 +++++++ .../Authorization/DispatchAuthorizerTest.php | 11 +- .../ProxiedAuthorizationResolverTest.php | 15 +- .../ExternalMechanismOptionsBuilderTest.php | 259 ++++++++++++++++++ .../SubjectDnCredentialMapperTest.php | 114 ++++++++ tests/unit/ServerOptionsTest.php | 28 ++ 36 files changed, 1833 insertions(+), 170 deletions(-) create mode 100644 src/FreeDSx/Ldap/Protocol/Authorization/AuthzId.php create mode 100644 src/FreeDSx/Ldap/Protocol/Authorization/AuthzIdResolver.php create mode 100644 src/FreeDSx/Ldap/Protocol/Authorization/AuthzIdType.php create mode 100644 src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/ExternalMechanismOptionsBuilder.php create mode 100644 src/FreeDSx/Ldap/Server/Sasl/External/ExternalCredentialMapperInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Sasl/External/SubjectDnCredentialMapper.php create mode 100644 tests/integration/Security/LdapExternalSaslServerTest.php create mode 100644 tests/resources/cert/test-cases/ext-ca.crt create mode 100644 tests/resources/cert/test-cases/ext-client.crt create mode 100644 tests/resources/cert/test-cases/ext-client.key create mode 100644 tests/resources/cert/test-cases/ext-nobody.crt create mode 100644 tests/resources/cert/test-cases/ext-nobody.key create mode 100755 tests/resources/cert/test-cases/generate-external-certs.sh create mode 100644 tests/unit/Protocol/Authorization/AuthzIdResolverTest.php create mode 100644 tests/unit/Protocol/Authorization/AuthzIdTest.php create mode 100644 tests/unit/Protocol/Bind/Sasl/OptionsBuilder/ExternalMechanismOptionsBuilderTest.php create mode 100644 tests/unit/Server/Sasl/External/SubjectDnCredentialMapperTest.php diff --git a/.gitignore b/.gitignore index f140d319..949c5f64 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ composer.lock composer.phar vendor/ .phpunit.result.cache -tests/resources/cert/ +# The OpenLDAP tooling dumps its generated certs here; ignore them, but keep the committed +# SASL EXTERNAL fixtures under test-cases/ (see test-cases/generate-external-certs.sh). +tests/resources/cert/* +!tests/resources/cert/test-cases/ diff --git a/composer.json b/composer.json index 3a2b2421..54140fbe 100644 --- a/composer.json +++ b/composer.json @@ -17,8 +17,8 @@ "require": { "php": ">=8.2", "freedsx/asn1": "dev-main#b57a38f", - "freedsx/socket": "dev-main#287974b", - "freedsx/sasl": "dev-main#85e1ef9", + "freedsx/socket": "dev-main#df083d9", + "freedsx/sasl": "dev-main#b886acb", "psr/log": "^3" }, "require-dev": { diff --git a/docs/Server/Configuration.md b/docs/Server/Configuration.md index ae2edbbf..5417cd5e 100644 --- a/docs/Server/Configuration.md +++ b/docs/Server/Configuration.md @@ -46,6 +46,7 @@ LDAP Server Configuration * [ServerOptions:setSearchLimitRules](#setsearchlimitrules) * [SASL Options](#sasl-options) * [ServerOptions:setSaslMechanisms](#setsaslmechanisms) + * [ServerOptions:setExternalCredentialMapper](#setexternalcredentialmapper) The LDAP server is configured through a `ServerOptions` object. The configuration object is passed to the server on construction: @@ -691,6 +692,7 @@ Use the constants defined on `ServerOptions` to specify mechanisms: | Constant | Mechanism | |-------------------------------------------|-----------------------| | `ServerOptions::SASL_PLAIN` | `PLAIN` | +| `ServerOptions::SASL_EXTERNAL` | `EXTERNAL` | | `ServerOptions::SASL_CRAM_MD5` | `CRAM-MD5` | | `ServerOptions::SASL_DIGEST_MD5` | `DIGEST-MD5` | | `ServerOptions::SASL_SCRAM_SHA_1` | `SCRAM-SHA-1` | @@ -730,6 +732,47 @@ $server = new LdapServer( **Note**: The `PLAIN` mechanism transmits credentials in cleartext. It should only be enabled when the connection is protected by TLS (via StartTLS or `setUseSsl`). +**Note**: The `EXTERNAL` mechanism authenticates from the verified TLS client certificate rather than `getSaslIdentity()`. +It requires a TLS connection **and** client-certificate validation (`setSslValidateCert(true)` with `setSslCaCert()`), +otherwise the bind is rejected. By default, the certificate subject DN is resolved via the identity resolver chain. You +can customize the mapping with [setExternalCredentialMapper](#setexternalcredentialmapper). + See [SASL Authentication](General-Usage.md#sasl-authentication) for full usage details. **Default**: `[]` (SASL disabled) + +------------------ +#### setExternalCredentialMapper + +Customizes how a verified TLS client certificate is mapped to an identity for the `EXTERNAL` mechanism. The +mapper returns an `AuthzId` (a `dn:`/`u:` identity) that is resolved through the identity resolver chain, or `null` to +reject the certificate. + +The default (`SubjectDnCredentialMapper`) reconstructs the certificate subject DN (X.509 order reversed to LDAP order) +and resolves it as a DN. Provide a custom mapper to instead map a SAN/UPN, rewrite the DN, or gate on the issuer: + +```php +use FreeDSx\Ldap\Protocol\Authorization\AuthzId; +use FreeDSx\Ldap\Server\Sasl\External\ExternalCredentialMapperInterface; +use FreeDSx\Socket\Tls\Certificate; + +$mapper = new class implements ExternalCredentialMapperInterface { + public function map(Certificate $certificate): ?AuthzId + { + // Resolve the entry by the certificate's subjectAltName via an attribute search. + $san = $certificate->getSubjectAltName(); + + return $san === null + ? null + : AuthzId::fromUsername($san); + } +}; + +$options->setSaslMechanisms(ServerOptions::SASL_EXTERNAL) + ->setExternalCredentialMapper($mapper); +``` + +A client may also send an authorization identity (`dn:`/`u:`) in the EXTERNAL credentials to act as a different +identity. This is only allowed when the certificate identity holds the proxied-authorization grant (RFC 4370). + +**Default**: `null` (the certificate subject DN mapper) diff --git a/src/FreeDSx/Ldap/ClientOptions.php b/src/FreeDSx/Ldap/ClientOptions.php index 53f4f995..41223f10 100644 --- a/src/FreeDSx/Ldap/ClientOptions.php +++ b/src/FreeDSx/Ldap/ClientOptions.php @@ -43,6 +43,10 @@ final class ClientOptions private ?string $sslCaCert = null; + private ?string $sslCert = null; + + private ?string $sslCertKey = null; + private ?string $sslPeerName = null; private int $timeoutConnect = 3; @@ -209,6 +213,36 @@ public function setSslCaCert(?string $sslCaCert): self return $this; } + public function getSslCert(): ?string + { + return $this->sslCert; + } + + /** + * The client certificate to present for mutual TLS (e.g. for a SASL EXTERNAL bind). + */ + public function setSslCert(?string $sslCert): self + { + $this->sslCert = $sslCert; + + return $this; + } + + public function getSslCertKey(): ?string + { + return $this->sslCertKey; + } + + /** + * The private key for the client certificate (omit when the key is bundled in the certificate file). + */ + public function setSslCertKey(?string $sslCertKey): self + { + $this->sslCertKey = $sslCertKey; + + return $this; + } + public function getSslPeerName(): ?string { return $this->sslPeerName; diff --git a/src/FreeDSx/Ldap/Container.php b/src/FreeDSx/Ldap/Container.php index 00829c7a..eff3b9b1 100644 --- a/src/FreeDSx/Ldap/Container.php +++ b/src/FreeDSx/Ldap/Container.php @@ -337,6 +337,8 @@ private function makeSocketPool(): SocketPool ->setSslValidateCert($clientOptions->isSslValidateCert()) ->setSslAllowSelfSigned($clientOptions->isSslAllowSelfSigned()) ->setSslCaCert($clientOptions->getSslCaCert()) + ->setSslCert($clientOptions->getSslCert()) + ->setSslCertKey($clientOptions->getSslCertKey()) ->setSslPeerName($clientOptions->getSslPeerName()) ->setTimeoutConnect($clientOptions->getTimeoutConnect()) ->setTimeoutRead($clientOptions->getTimeoutRead()); diff --git a/src/FreeDSx/Ldap/Protocol/Authorization/AuthzId.php b/src/FreeDSx/Ldap/Protocol/Authorization/AuthzId.php new file mode 100644 index 00000000..034d17b2 --- /dev/null +++ b/src/FreeDSx/Ldap/Protocol/Authorization/AuthzId.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Protocol\Authorization; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Exception\InvalidArgumentException; + +use function sprintf; +use function str_starts_with; +use function strlen; +use function substr; + +/** + * An authorization identity in SASL/RFC 4513 form: a typed value plus its "dn:" /"u:" / anonymous form. + * + * @author Chad Sikorra + */ +final readonly class AuthzId +{ + private function __construct( + private AuthzIdType $type, + private string $value = '', + ) {} + + public function __toString(): string + { + return $this->toString(); + } + + /** + * Parse the "dn:" / "u:" form; an empty string is the anonymous identity. + * + * @throws InvalidArgumentException on an unrecognized (non-dn/u) form + */ + public static function fromString(string $authzId): self + { + return match (true) { + $authzId === '' => new self(AuthzIdType::Anonymous), + str_starts_with($authzId, AuthzIdType::Dn->value) => new self( + AuthzIdType::Dn, + substr($authzId, strlen(AuthzIdType::Dn->value)), + ), + str_starts_with($authzId, AuthzIdType::Username->value) => new self( + AuthzIdType::Username, + substr($authzId, strlen(AuthzIdType::Username->value)), + ), + default => throw new InvalidArgumentException(sprintf( + 'The authorization identity "%s" must use the "dn:" or "u:" form.', + $authzId, + )), + }; + } + + public static function fromDn(Dn $dn): self + { + return new self( + AuthzIdType::Dn, + $dn->toString(), + ); + } + + public static function fromUsername(string $username): self + { + return new self( + AuthzIdType::Username, + $username, + ); + } + + public function isType(AuthzIdType $type): bool + { + return $this->type === $type; + } + + /** + * The identity value without its form prefix (the DN string, or the username; empty when anonymous). + */ + public function getValue(): string + { + return $this->value; + } + + public function toString(): string + { + return $this->type->value . $this->value; + } +} diff --git a/src/FreeDSx/Ldap/Protocol/Authorization/AuthzIdResolver.php b/src/FreeDSx/Ldap/Protocol/Authorization/AuthzIdResolver.php new file mode 100644 index 00000000..438f93b7 --- /dev/null +++ b/src/FreeDSx/Ldap/Protocol/Authorization/AuthzIdResolver.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Protocol\Authorization; + +use FreeDSx\Ldap\Control\Control; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\InvalidArgumentException; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Exception\UnexpectedValueException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Logging\EventContext; +use FreeDSx\Ldap\Server\Logging\EventLogger; +use FreeDSx\Ldap\Server\Logging\ServerEvent; +use FreeDSx\Ldap\Server\Token\AnonToken; +use FreeDSx\Ldap\Server\Token\AuthenticatedTokenInterface; +use FreeDSx\Ldap\Server\Token\BindToken; +use FreeDSx\Ldap\Server\Token\TokenInterface; + +/** + * Resolves an authorization identity to an entry, and authorizes an identity to assume one (RFC 4370 grant). + * + * @author Chad Sikorra + */ +final readonly class AuthzIdResolver +{ + public function __construct( + private AccessControlInterface $accessControl, + private LdapBackendInterface $backend, + private BindNameResolverInterface $identityResolver, + private EventLogger $eventLogger, + ) {} + + /** + * Resolve an authzId to its directory entry, or null when anonymous, malformed, or with no match. + */ + public function resolve(AuthzId $authzId): ?Entry + { + try { + return match (true) { + $authzId->isType(AuthzIdType::Dn) => $this->backend->get(new Dn($authzId->getValue())), + $authzId->isType(AuthzIdType::Username) => $this->identityResolver->resolve( + $authzId->getValue(), + $this->backend, + ), + default => null, + }; + } catch (OperationException|UnexpectedValueException|InvalidArgumentException) { + return null; + } + } + + /** + * Authorize the authenticated identity to act as the requested authzId, returning the effective token. + * + * @throws OperationException when the assumption is not permitted + */ + public function assume( + AuthenticatedTokenInterface $token, + AuthzId $authzId, + ): TokenInterface { + // a caller with no proxy grant cannot drive resolution. + if (!$this->accessControl->mayUseControl($token, Control::OID_PROXY_AUTHORIZATION)) { + $this->deny( + $token, + $authzId->toString(), + ); + } + + if ($authzId->isType(AuthzIdType::Anonymous)) { + $this->authorize( + $token, + new Dn(''), + $authzId, + ); + + return new AnonToken( + null, + $token->getVersion(), + $token->getResolvedDn(), + ); + } + + $entry = $this->resolve($authzId); + if ($entry === null) { + $this->deny( + $token, + $authzId->toString(), + ); + } + $proxiedDn = $entry->getDn(); + $this->authorize( + $token, + $proxiedDn, + $authzId, + ); + + return BindToken::fromSasl( + username: $proxiedDn->toString(), + resolvedDn: $proxiedDn, + version: $token->getVersion(), + authorizingDn: $token->getResolvedDn(), + ); + } + + /** + * Record the denial (with the attempted authzId for audit) and throw a generic denial to the client. + * + * @throws OperationException + */ + public function deny( + TokenInterface $token, + string $authzId, + ): never { + $exception = new OperationException( + 'Proxied authorization denied.', + ResultCode::AUTHORIZATION_DENIED, + ); + $this->eventLogger->recordFailure( + ServerEvent::ProxyAuthorizationDenied, + $exception, + [ + EventContext::CONTROL_OIDS => [Control::OID_PROXY_AUTHORIZATION], + EventContext::AUTHZ_ID => $authzId, + ], + subject: $token, + ); + + throw $exception; + } + + /** + * @throws OperationException + */ + private function authorize( + AuthenticatedTokenInterface $token, + Dn $proxiedDn, + AuthzId $authzId, + ): void { + try { + $this->accessControl->authorizeControl( + $token, + $proxiedDn, + Control::OID_PROXY_AUTHORIZATION, + ); + } catch (OperationException) { + $this->deny( + $token, + $authzId->toString(), + ); + } + } +} diff --git a/src/FreeDSx/Ldap/Protocol/Authorization/AuthzIdType.php b/src/FreeDSx/Ldap/Protocol/Authorization/AuthzIdType.php new file mode 100644 index 00000000..9dafdf0a --- /dev/null +++ b/src/FreeDSx/Ldap/Protocol/Authorization/AuthzIdType.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Protocol\Authorization; + +/** + * The form of an authorization identity. + * + * @author Chad Sikorra + */ +enum AuthzIdType: string +{ + case Anonymous = ''; + + case Dn = 'dn:'; + + case Username = 'u:'; +} diff --git a/src/FreeDSx/Ldap/Protocol/Authorization/ProxiedAuthorizationResolver.php b/src/FreeDSx/Ldap/Protocol/Authorization/ProxiedAuthorizationResolver.php index 3312aaf5..d5d8dca0 100644 --- a/src/FreeDSx/Ldap/Protocol/Authorization/ProxiedAuthorizationResolver.php +++ b/src/FreeDSx/Ldap/Protocol/Authorization/ProxiedAuthorizationResolver.php @@ -13,34 +13,20 @@ namespace FreeDSx\Ldap\Protocol\Authorization; -use FreeDSx\Ldap\Control\Control; use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Control\ProxyAuthorizationControl; -use FreeDSx\Ldap\Entry\Dn; -use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\InvalidArgumentException; use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Exception\UnexpectedValueException; use FreeDSx\Ldap\Operation\Request\AbandonRequest; use FreeDSx\Ldap\Operation\Request\ExtendedRequest; use FreeDSx\Ldap\Operation\Request\RequestInterface; use FreeDSx\Ldap\Operation\Request\UnbindRequest; use FreeDSx\Ldap\Operation\ResultCode; -use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; -use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; -use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\Logging\EventContext; -use FreeDSx\Ldap\Server\Logging\EventLogger; -use FreeDSx\Ldap\Server\Logging\ServerEvent; -use FreeDSx\Ldap\Server\Token\AnonToken; use FreeDSx\Ldap\Server\Token\AuthenticatedTokenInterface; -use FreeDSx\Ldap\Server\Token\BindToken; use FreeDSx\Ldap\Server\Token\TokenInterface; use function count; use function in_array; -use function str_starts_with; -use function substr; /** * Resolves the effective identity for an operation carrying an RFC 4370 proxied authorization control. @@ -59,10 +45,7 @@ ]; public function __construct( - private AccessControlInterface $accessControl, - private LdapBackendInterface $backend, - private BindNameResolverInterface $identityResolver, - private EventLogger $eventLogger, + private AuthzIdResolver $authzIdResolver, ) {} /** @@ -106,26 +89,27 @@ public function resolve( ); } - $authzId = $control->getAuthzId(); + $rawAuthzId = $control->getAuthzId(); if (!$token instanceof AuthenticatedTokenInterface) { - $this->denyProxy( + $this->authzIdResolver->deny( $token, - $authzId, + $rawAuthzId, ); } - # Coarse capability gate before any directory lookup, so a caller with no proxy grant cannot drive resolution. - if (!$this->accessControl->mayUseControl($token, Control::OID_PROXY_AUTHORIZATION)) { - $this->denyProxy( + try { + $authzId = AuthzId::fromString($rawAuthzId); + } catch (InvalidArgumentException) { + $this->authzIdResolver->deny( $token, - $authzId, + $rawAuthzId, ); } - return $this->effectiveToken( - $authzId, + return $this->authzIdResolver->assume( $token, + $authzId, ); } @@ -157,124 +141,4 @@ private function isProxyEligible(RequestInterface $request): bool return !($request instanceof UnbindRequest || $request instanceof AbandonRequest); } - - /** - * @throws OperationException - */ - private function effectiveToken( - string $authzId, - AuthenticatedTokenInterface $token, - ): TokenInterface { - if ($authzId === '') { - $this->authorizeProxy( - $token, - new Dn(''), - $authzId, - ); - - return new AnonToken( - null, - $token->getVersion(), - $token->getResolvedDn(), - ); - } - - $proxiedDn = $this->resolveAuthzId( - $authzId, - $token, - )->getDn(); - $this->authorizeProxy( - $token, - $proxiedDn, - $authzId, - ); - - return BindToken::fromSasl( - username: $proxiedDn->toString(), - resolvedDn: $proxiedDn, - version: $token->getVersion(), - authorizingDn: $token->getResolvedDn(), - ); - } - - /** - * Resolves an authzId ("dn:..." / "u:...") to its directory entry; denies on an unknown form, malformed DN, or - * no match. - * - * @throws OperationException - */ - private function resolveAuthzId( - string $authzId, - AuthenticatedTokenInterface $token, - ): Entry { - try { - $entry = match (true) { - str_starts_with($authzId, 'dn:') => $this->backend->get(new Dn(substr($authzId, 3))), - str_starts_with($authzId, 'u:') => $this->identityResolver->resolve(substr($authzId, 2), $this->backend), - default => null, - }; - } catch (OperationException|UnexpectedValueException|InvalidArgumentException) { - $this->denyProxy( - $token, - $authzId, - ); - } - - if ($entry === null) { - $this->denyProxy( - $token, - $authzId, - ); - } - - return $entry; - } - - /** - * @throws OperationException - */ - private function authorizeProxy( - AuthenticatedTokenInterface $token, - Dn $proxiedDn, - string $authzId, - ): void { - try { - $this->accessControl->authorizeControl( - $token, - $proxiedDn, - Control::OID_PROXY_AUTHORIZATION, - ); - } catch (OperationException) { - $this->denyProxy( - $token, - $authzId, - ); - } - } - - /** - * Records the denial (including the attempted authzId for audit) and throws a generic denial to the client. - * - * @throws OperationException - */ - private function denyProxy( - TokenInterface $token, - string $authzId, - ): never { - $exception = new OperationException( - 'Proxied authorization denied.', - ResultCode::AUTHORIZATION_DENIED, - ); - $this->eventLogger->recordFailure( - ServerEvent::ProxyAuthorizationDenied, - $exception, - [ - EventContext::CONTROL_OIDS => [Control::OID_PROXY_AUTHORIZATION], - EventContext::AUTHZ_ID => $authzId, - ], - subject: $token, - ); - - throw $exception; - } } diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/ExternalMechanismOptionsBuilder.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/ExternalMechanismOptionsBuilder.php new file mode 100644 index 00000000..489b5617 --- /dev/null +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/ExternalMechanismOptionsBuilder.php @@ -0,0 +1,172 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\InvalidArgumentException; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Protocol\Authorization\AuthzId; +use FreeDSx\Ldap\Protocol\Authorization\AuthzIdResolver; +use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Server\Sasl\External\ExternalCredentialMapperInterface; +use FreeDSx\Ldap\Server\Token\AuthenticatedTokenInterface; +use FreeDSx\Ldap\Server\Token\BindToken; +use FreeDSx\Ldap\ServerOptions; +use FreeDSx\Sasl\Mechanism\MechanismName; +use FreeDSx\Sasl\Options\ChallengeOptionsInterface; +use FreeDSx\Sasl\Options\ExternalOptions; + +/** + * Builds the SASL EXTERNAL options: validate the verified client certificate and resolve its directory identity. + * + * @author Chad Sikorra + */ +final class ExternalMechanismOptionsBuilder implements MechanismOptionsBuilderInterface +{ + private ?Dn $resolvedDn = null; + + private ?string $username = null; + + private ?Dn $authorizingDn = null; + + public function __construct( + private readonly ServerQueue $queue, + private readonly ServerOptions $options, + private readonly ExternalCredentialMapperInterface $mapper, + private readonly AuthzIdResolver $authzIdResolver, + ) {} + + public function buildOptions( + ?string $received, + MechanismName $mechanism, + ): ChallengeOptionsInterface { + return (new ExternalOptions())->setValidate( + fn(?string $authzId): bool => $this->validate($authzId), + ); + } + + public function getResolvedDn(): ?Dn + { + return $this->resolvedDn; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function getAuthorizingDn(): ?Dn + { + return $this->authorizingDn; + } + + /** + * @throws OperationException when the channel is unsuitable for EXTERNAL or an assumed authzId is denied + */ + private function validate(?string $clientAuthzId): bool + { + $this->assertSecureChannel(); + + $certificate = $this->queue->peerCertificate(); + if ($certificate === null) { + throw new OperationException( + 'No client certificate was provided for SASL EXTERNAL.', + ResultCode::INAPPROPRIATE_AUTHENTICATION, + ); + } + + $authcId = $this->mapper->map($certificate); + if ($authcId === null) { + return false; + } + + $authcEntry = $this->authzIdResolver->resolve($authcId); + if ($authcEntry === null) { + return false; + } + + $token = $this->effectiveToken( + $authcEntry, + $clientAuthzId, + ); + if ($token === null) { + return false; + } + + $this->resolvedDn = $token->getResolvedDn(); + $this->username = $token->getUsername(); + $this->authorizingDn = $token->getAuthorizingDn(); + + return true; + } + + /** + * The certificate identity, or the authzId it is authorized to assume. + * + * Returns null when the client authzId is malformed. + * + * @throws OperationException when the assumed authzId is denied + */ + private function effectiveToken( + Entry $authcEntry, + ?string $clientAuthzId, + ): ?AuthenticatedTokenInterface { + $authcToken = BindToken::fromSasl( + $authcEntry->getDn()->toString(), + $authcEntry->getDn(), + ); + + if ($clientAuthzId === null || $clientAuthzId === '') { + return $authcToken; + } + + try { + $authzId = AuthzId::fromString($clientAuthzId); + } catch (InvalidArgumentException) { + return null; + } + + $assumed = $this->authzIdResolver->assume( + $authcToken, + $authzId, + ); + + return $assumed instanceof AuthenticatedTokenInterface + ? $assumed + : null; + } + + /** + * @throws OperationException when the connection is not a verified-client-certificate TLS channel + */ + private function assertSecureChannel(): void + { + // LDAPS connections are encrypted from accept (the StartTLS-only isEncrypted flag is not set for them). + if (!$this->options->isUseSsl() && !$this->queue->isEncrypted()) { + throw new OperationException( + 'SASL EXTERNAL requires a TLS-protected connection.', + ResultCode::CONFIDENTIALITY_REQUIRED, + ); + } + + if (!$this->options->isSslValidateCert()) { + throw new OperationException( + 'SASL EXTERNAL requires client certificate validation to be enabled.', + ResultCode::INAPPROPRIATE_AUTHENTICATION, + ); + } + } +} diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php index c7da29ba..c380fc1c 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderFactory.php @@ -13,6 +13,7 @@ namespace FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder; +use Closure; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\Bind\Sasl\UsernameExtractor\UsernameFieldExtractor; @@ -24,10 +25,14 @@ * * @author Chad Sikorra */ -final class MechanismOptionsBuilderFactory +final readonly class MechanismOptionsBuilderFactory { + /** + * @param (Closure(): MechanismOptionsBuilderInterface)|null $externalBuilderFactory builds a fresh EXTERNAL builder + */ public function __construct( - private readonly PasswordAuthenticatableInterface $authenticator, + private PasswordAuthenticatableInterface $authenticator, + private ?Closure $externalBuilderFactory = null, ) {} /** @@ -44,6 +49,8 @@ public function make(MechanismName $mechanism): MechanismOptionsBuilderInterface => new DigestMD5MechanismOptionsBuilder($this->authenticator, new UsernameFieldExtractor()), $mechanism->isScram() => new ScramMechanismOptionsBuilder($this->authenticator), + $mechanism === MechanismName::EXTERNAL && $this->externalBuilderFactory !== null + => ($this->externalBuilderFactory)(), default => throw new OperationException( sprintf('The SASL mechanism "%s" is not supported.', $mechanism->value), ResultCode::OTHER, diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderInterface.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderInterface.php index 27772044..9b4c3ecd 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderInterface.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/MechanismOptionsBuilderInterface.php @@ -36,4 +36,14 @@ public function buildOptions( * The resolved directory DN for the authenticated identity, available after the exchange completes. */ public function getResolvedDn(): ?Dn; + + /** + * The username the mechanism resolved directly, when it is not carried in the SASL credentials (else null). + */ + public function getUsername(): ?string; + + /** + * The authorizing identity's DN when an authorization identity was assumed for the bind (else null). + */ + public function getAuthorizingDn(): ?Dn; } diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/RequireIdentityTrait.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/RequireIdentityTrait.php index 5adca5da..1dd8f02d 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/RequireIdentityTrait.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/OptionsBuilder/RequireIdentityTrait.php @@ -32,6 +32,16 @@ public function getResolvedDn(): ?Dn return $this->resolvedDn; } + public function getUsername(): ?string + { + return null; + } + + public function getAuthorizingDn(): ?Dn + { + return null; + } + /** * Validates the identity, stores its resolved DN, and returns it. * diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php index 4a344486..11b16746 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchange.php @@ -83,11 +83,12 @@ public function run(SaslExchangeInput $input): SaslExchangeResult return new SaslExchangeResult( $context, $message, - $this->extractUsername( + $optionsBuilder->getUsername() ?? $this->extractUsername( $usernameCredentials, $mechName, ), $optionsBuilder->getResolvedDn(), + $optionsBuilder->getAuthorizingDn(), ); } diff --git a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchangeResult.php b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchangeResult.php index 73d2d398..4bc23496 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchangeResult.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/Sasl/SaslExchangeResult.php @@ -29,6 +29,7 @@ public function __construct( private LdapMessageRequest $lastMessage, private ?string $username, private ?Dn $resolvedDn = null, + private ?Dn $authorizingDn = null, ) {} public function getContext(): SaslContext @@ -50,4 +51,9 @@ public function getResolvedDn(): ?Dn { return $this->resolvedDn; } + + public function getAuthorizingDn(): ?Dn + { + return $this->authorizingDn; + } } diff --git a/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php b/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php index d5958e88..7c80f28e 100644 --- a/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php +++ b/src/FreeDSx/Ldap/Protocol/Bind/SaslBind.php @@ -245,6 +245,7 @@ private function finalize( $token = BindToken::fromSasl( $username, $resolvedDn, + authorizingDn: $result->getAuthorizingDn(), ); if ($mustChangePassword) { $token->markMustChangePassword(); diff --git a/src/FreeDSx/Ldap/Protocol/LdapQueue.php b/src/FreeDSx/Ldap/Protocol/LdapQueue.php index fe2e933e..55254b9f 100644 --- a/src/FreeDSx/Ldap/Protocol/LdapQueue.php +++ b/src/FreeDSx/Ldap/Protocol/LdapQueue.php @@ -27,6 +27,7 @@ use FreeDSx\Socket\Queue\Buffer; use FreeDSx\Socket\Queue\Message; use FreeDSx\Socket\Socket; +use FreeDSx\Socket\Tls\Certificate; use function strlen; use function substr; @@ -74,6 +75,11 @@ public function isEncrypted(): bool return ($this->socket->isConnected() && $this->socket->isEncrypted()); } + public function peerCertificate(): ?Certificate + { + return $this->socket->getPeerCertificate(); + } + /** * Cleanly close the socket and clear buffer contents. */ diff --git a/src/FreeDSx/Ldap/Server/Sasl/External/ExternalCredentialMapperInterface.php b/src/FreeDSx/Ldap/Server/Sasl/External/ExternalCredentialMapperInterface.php new file mode 100644 index 00000000..1081d607 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Sasl/External/ExternalCredentialMapperInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Sasl\External; + +use FreeDSx\Ldap\Protocol\Authorization\AuthzId; +use FreeDSx\Socket\Tls\Certificate; + +/** + * Maps a verified TLS client certificate to an authorization identity for SASL EXTERNAL, resolved via the chain. + */ +interface ExternalCredentialMapperInterface +{ + /** + * The authentication identity for the certificate (a dn:/u: authzId resolved via the identity resolver chain). + * + * Returning null rejects the certificate. + */ + public function map(Certificate $certificate): ?AuthzId; +} diff --git a/src/FreeDSx/Ldap/Server/Sasl/External/SubjectDnCredentialMapper.php b/src/FreeDSx/Ldap/Server/Sasl/External/SubjectDnCredentialMapper.php new file mode 100644 index 00000000..3b55e41c --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Sasl/External/SubjectDnCredentialMapper.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Sasl\External; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Rdn; +use FreeDSx\Ldap\Protocol\Authorization\AuthzId; +use FreeDSx\Socket\Tls\Certificate; + +use function array_map; +use function array_reverse; +use function implode; +use function is_array; + +/** + * Default EXTERNAL mapper. + * + * The certificate subject DN, resolved as-is via the identity resolver chain. + * + * @author Chad Sikorra + */ +final class SubjectDnCredentialMapper implements ExternalCredentialMapperInterface +{ + public function map(Certificate $certificate): ?AuthzId + { + $components = []; + foreach ($certificate->getSubject() as $attribute => $value) { + $components = [ + ...$components, + ...$this->escapeComponents($attribute, $value), + ]; + } + + if ($components === []) { + return null; + } + + // X.509 subjects are ordered most-general first; an LDAP DN is most-specific first. + return AuthzId::fromDn(new Dn(implode( + ',', + array_reverse($components), + ))); + } + + /** + * The escaped "attr=value" RDN component(s) for one subject attribute (expanding multi-valued attributes, e.g. DC). + * + * @param string|list $value + * + * @return list + */ + private function escapeComponents( + string $attribute, + string|array $value, + ): array { + return array_map( + fn(string $single): string => $attribute . '=' . Rdn::escape($single), + is_array($value) ? $value : [$value], + ); + } +} diff --git a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php index e4b94617..c94d6d98 100644 --- a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php +++ b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php @@ -14,9 +14,13 @@ namespace FreeDSx\Ldap\Server; use FreeDSx\Ldap\Protocol\Authenticator; +use FreeDSx\Ldap\Protocol\Authorization\AuthzIdResolver; use FreeDSx\Ldap\Protocol\Authorization\DispatchAuthorizer; use FreeDSx\Ldap\Protocol\Authorization\ProxiedAuthorizationResolver; +use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\ExternalMechanismOptionsBuilder; use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\MechanismOptionsBuilderFactory; +use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\MechanismOptionsBuilderInterface; +use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Protocol\Bind\Sasl\SaslExchange; use FreeDSx\Ldap\Protocol\Bind\SaslBind; use FreeDSx\Ldap\Protocol\Bind\SimpleBind; @@ -34,6 +38,7 @@ use FreeDSx\Ldap\Protocol\ServerProtocolHandler; use FreeDSx\Ldap\Server\Backend\Auth\PasswordPolicyAwareAuthenticator; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Sasl\External\SubjectDnCredentialMapper; use FreeDSx\Ldap\Server\Backend\Auth\SaslBindPolicyEnforcer; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Write\SystemChangeWriter; @@ -69,6 +74,8 @@ use FreeDSx\Ldap\ServerOptions; use FreeDSx\Socket\Socket; +use function in_array; + class ServerProtocolFactory implements ServerProtocolFactoryInterface { use ServerConnectionScaffoldingTrait; @@ -130,6 +137,14 @@ public function make( $eventLogger, ), ]; + + $authzIdResolver = new AuthzIdResolver( + $this->options->getAccessControl(), + $backend, + $this->handlerFactory->makeIdentityResolverChain(), + $eventLogger, + ); + $saslMechanisms = $this->options->getSaslMechanisms(); if (!empty($saslMechanisms)) { @@ -139,7 +154,12 @@ public function make( exchange: new SaslExchange( $serverQueue, $responseFactory, - new MechanismOptionsBuilderFactory($passwordAuthenticator), + $this->makeOptionsBuilderFactory( + $passwordAuthenticator, + $serverQueue, + $saslMechanisms, + $authzIdResolver, + ), ), sasl: new Sasl(new SaslOptions( supported: $this->parseKnownMechanisms($saslMechanisms), @@ -159,12 +179,7 @@ public function make( $dispatchAuthorizer = new DispatchAuthorizer( $authorization, new PasswordResetGate(), - new ProxiedAuthorizationResolver( - $this->options->getAccessControl(), - $backend, - $this->handlerFactory->makeIdentityResolverChain(), - $eventLogger, - ), + new ProxiedAuthorizationResolver($authzIdResolver), ); $searchLimitResolver = new SearchLimitResolver( @@ -289,6 +304,30 @@ private function makeBindGuard( ); } + /** + * @param string[] $saslMechanisms + */ + private function makeOptionsBuilderFactory( + PasswordAuthenticatableInterface $authenticator, + ServerQueue $queue, + array $saslMechanisms, + AuthzIdResolver $authzIdResolver, + ): MechanismOptionsBuilderFactory { + if (!in_array(ServerOptions::SASL_EXTERNAL, $saslMechanisms, true)) { + return new MechanismOptionsBuilderFactory($authenticator); + } + + return new MechanismOptionsBuilderFactory( + $authenticator, + fn(): MechanismOptionsBuilderInterface => new ExternalMechanismOptionsBuilder( + $queue, + $this->options, + $this->options->getExternalCredentialMapper() ?? new SubjectDnCredentialMapper(), + $authzIdResolver, + ), + ); + } + /** * Converts mechanism name strings into known MechanismName values, discarding unrecognised ones. * diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index 4a59a50a..803bb7c9 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -22,6 +22,7 @@ use FreeDSx\Ldap\Schema\StandardSchemaProvider; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicy; use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; +use FreeDSx\Ldap\Server\Sasl\External\ExternalCredentialMapperInterface; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashScheme; use FreeDSx\Ldap\Server\PasswordPolicy\QualityCheck\DefaultPasswordQualityChecker; @@ -56,6 +57,8 @@ final class ServerOptions public const SASL_DIGEST_MD5 = 'DIGEST-MD5'; + public const SASL_EXTERNAL = 'EXTERNAL'; + public const SASL_SCRAM_SHA_1 = 'SCRAM-SHA-1'; public const SASL_SCRAM_SHA_1_PLUS = 'SCRAM-SHA-1-PLUS'; @@ -84,6 +87,7 @@ final class ServerOptions self::SASL_PLAIN, self::SASL_CRAM_MD5, self::SASL_DIGEST_MD5, + self::SASL_EXTERNAL, self::SASL_SCRAM_SHA_1, self::SASL_SCRAM_SHA_1_PLUS, self::SASL_SCRAM_SHA_224, @@ -154,6 +158,8 @@ final class ServerOptions private ?BindNameResolverInterface $identityResolver = null; + private ?ExternalCredentialMapperInterface $externalCredentialMapper = null; + private ?RootDseHandlerInterface $rootDseHandler = null; /** @@ -525,6 +531,21 @@ public function setIdentityResolver(?BindNameResolverInterface $identityResolver return $this; } + public function getExternalCredentialMapper(): ?ExternalCredentialMapperInterface + { + return $this->externalCredentialMapper; + } + + /** + * Custom cert->identity policy for SASL EXTERNAL (e.g. map a SAN/UPN or rewrite the DN); null uses the subject DN. + */ + public function setExternalCredentialMapper(?ExternalCredentialMapperInterface $externalCredentialMapper): self + { + $this->externalCredentialMapper = $externalCredentialMapper; + + return $this; + } + public function getRootDseHandler(): ?RootDseHandlerInterface { return $this->rootDseHandler; diff --git a/tests/integration/Security/LdapExternalSaslServerTest.php b/tests/integration/Security/LdapExternalSaslServerTest.php new file mode 100644 index 00000000..39b4177d --- /dev/null +++ b/tests/integration/Security/LdapExternalSaslServerTest.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Integration\FreeDSx\Ldap\Security; + +use FreeDSx\Ldap\ClientOptions; +use FreeDSx\Ldap\Exception\BindException; +use FreeDSx\Ldap\LdapClient; +use FreeDSx\Ldap\Operation\Response\BindResponse; +use FreeDSx\Sasl\Mechanism\MechanismName; +use FreeDSx\Sasl\Options\ExternalOptions; +use Tests\Integration\FreeDSx\Ldap\ServerTestCase; +use Throwable; + +use function strtolower; + +final class LdapExternalSaslServerTest extends ServerTestCase +{ + /** + * Client cert with subject "/DC=bar/DC=foo/CN=extuser" -> maps to the seeded cn=extuser,dc=foo,dc=bar. + */ + private const CLIENT_CERT = __DIR__ . '/../../resources/cert/test-cases/ext-client.crt'; + + private const CLIENT_KEY = __DIR__ . '/../../resources/cert/test-cases/ext-client.key'; + + /** + * Client cert whose subject maps to cn=nobody,dc=foo,dc=bar, which is not seeded. + */ + private const NOBODY_CERT = __DIR__ . '/../../resources/cert/test-cases/ext-nobody.crt'; + + private const NOBODY_KEY = __DIR__ . '/../../resources/cert/test-cases/ext-nobody.key'; + + /** + * @var LdapClient[] + */ + private array $certificateClients = []; + + public function setUp(): void + { + $this->setServerMode('ldap-server'); + + parent::setUp(); + + $this->createServerProcess( + 'ssl', + ['--external'], + ); + } + + public function tearDown(): void + { + foreach ($this->certificateClients as $client) { + try { + $client->unbind(); + } catch (Throwable) { + // The connection may already be closed; ignore unbind failures. + } + } + $this->certificateClients = []; + + parent::tearDown(); + } + + public function testItBindsViaSaslExternalAsTheCertificateIdentity(): void + { + $client = $this->clientWithCertificate( + self::CLIENT_CERT, + self::CLIENT_KEY, + ); + + $response = $client + ->bindSasl(new ExternalOptions(), MechanismName::EXTERNAL) + ->getResponse(); + + self::assertInstanceOf( + BindResponse::class, + $response, + ); + self::assertSame( + 0, + $response->getResultCode(), + ); + self::assertSame( + 'dn:cn=extuser,dc=foo,dc=bar', + strtolower((string) $client->whoami()), + ); + } + + public function testTheRootDseAdvertisesTheExternalMechanism(): void + { + $client = $this->clientWithCertificate( + self::CLIENT_CERT, + self::CLIENT_KEY, + ); + $client->bindSasl(new ExternalOptions(), MechanismName::EXTERNAL); + + $rootDse = $client->read( + '', + ['supportedSaslMechanisms'], + ); + + self::assertNotNull($rootDse); + self::assertContains( + 'EXTERNAL', + $rootDse->get('supportedSaslMechanisms')?->getValues() ?? [], + ); + } + + public function testAnUnmappedCertificateIsRejected(): void + { + $client = $this->clientWithCertificate( + self::NOBODY_CERT, + self::NOBODY_KEY, + ); + + $this->expectException(BindException::class); + + $client->bindSasl(new ExternalOptions(), MechanismName::EXTERNAL); + } + + private function clientWithCertificate( + string $cert, + string $key, + ): LdapClient { + $client = new LdapClient( + (new ClientOptions()) + ->setServers(['127.0.0.1']) + ->setPort(10389) + ->setTransport('tcp') + ->setUseSsl(true) + ->setSslValidateCert(false) + ->setSslCert($cert) + ->setSslCertKey($key), + ); + $this->certificateClients[] = $client; + + return $client; + } +} diff --git a/tests/resources/cert/test-cases/ext-ca.crt b/tests/resources/cert/test-cases/ext-ca.crt new file mode 100644 index 00000000..d48e0f5a --- /dev/null +++ b/tests/resources/cert/test-cases/ext-ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKTCCAhGgAwIBAgIUG+YYCOeMmnFFSn8ay42JzLOB+GowDQYJKoZIhvcNAQEL +BQAwIzEhMB8GA1UEAwwYRnJlZURTeCBUZXN0IEV4dGVybmFsIENBMCAXDTI2MDYw +ODEyMTMxNFoYDzIxMjYwNTE1MTIxMzE0WjAjMSEwHwYDVQQDDBhGcmVlRFN4IFRl +c3QgRXh0ZXJuYWwgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCX +d5uQ94sd962l5zz6kfgBh2WeU9wvb4+KN+vtfTOLNb3QNy9pcXa3+Ji7W/S5iqvK +cOHsYIYRBL24omRJcRoec/mduqqnpOvaIRaSCDkdFJLKezSROSUF/e+XM4kfm3YB +IDjfxTujhg2JiBC+spZD3JVuCa7zwYVBXg6fECnUpvXx5LlxRYvI4oV1XYsVdgza +hlsAnrcQ0+7KuJJm6ol3obkqVwtscKmUjLeGdB7TNonj0iQvHPYC++wAc5oJrJXX +r5C5tkkX7ZEynZ4UsrMJKvSWRUQDV9h9/KKxoql9pOW3I3e1jVxTjkkF9sI0d6kM +ql9vqdo6tm1+EQDqag//AgMBAAGjUzBRMB0GA1UdDgQWBBQubQ81asMtcYpk5NBl +ZEdezJyVXjAfBgNVHSMEGDAWgBQubQ81asMtcYpk5NBlZEdezJyVXjAPBgNVHRMB +Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB5J6VgY+DwV3CcboDCVsgPEubP +ptb0NhurTgWrn80TjmtbfRaew13fYst31t3vicPEmP2cEvnVP2txaUyBczmSBsrX +O8DMK8owbPOmWhEchbchHtMUUibjVPXo5KoaKN99P3qoUtAKStNOs5xCwav0c722 +0ag7xko90IZbd72wm3q2ak7Ico2iBOBzd4urr0x9pmFRkB/emDM6YDURQ58Z5FM4 +tThR64/4820ROmP7yvQnxtIvpN1M5aGCOXAC7oF5RSvzYOyy+6p9dgNk8+fN1M7Z +QP66EEBNigfXMsCTcyLKUurIHLJV5wFZ9PbOjgIC9d+WmXcdhIK0vFPdrxgm +-----END CERTIFICATE----- diff --git a/tests/resources/cert/test-cases/ext-client.crt b/tests/resources/cert/test-cases/ext-client.crt new file mode 100644 index 00000000..200a1e14 --- /dev/null +++ b/tests/resources/cert/test-cases/ext-client.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDMTCCAhmgAwIBAgIUbH/ZCmsSxwHnCBIvfudKD+ZF8GIwDQYJKoZIhvcNAQEL +BQAwIzEhMB8GA1UEAwwYRnJlZURTeCBUZXN0IEV4dGVybmFsIENBMCAXDTI2MDYw +ODEyMTMxNFoYDzIxMjYwNTE1MTIxMzE0WjA8MRMwEQYKCZImiZPyLGQBGRYDYmFy +MRMwEQYKCZImiZPyLGQBGRYDZm9vMRAwDgYDVQQDDAdleHR1c2VyMIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwAHfFvD0UomhB/dCwPoStsRexVMBbKrI +FuuhyXCgrWPwqpSAq4vQjX6QvJN27wcwGxNIiIlchxb8T5scA8vEMTL2RfJRGUBZ +GGTss+vCfbdy2Fs40GS6KPZH/c8LHdp4zZb58fuKFI96TnXxbc27Xy+1irmZpDpp +JY3PDqUsUBqjpuAh6lVbbzyvet59xIM7oxMMaaZ0Dd5qId8KBLh7C9PEzu81O8yY +Mdg/a6ctzt7U6CrKFpBJRcRRVLpleYrs81wwvr9Dkr4ruLntwoM4JXpraVPVa9r1 +zFpC4xxpl80dzy0ZpmZQkVM1mbGLHo3cxUe+/lXWrugTS5I9us4gZwIDAQABo0Iw +QDAdBgNVHQ4EFgQU9CAvc2hPJlgTDw8p/uDcfIrE/7wwHwYDVR0jBBgwFoAULm0P +NWrDLXGKZOTQZWRHXsyclV4wDQYJKoZIhvcNAQELBQADggEBAAIuhwhAxFjngdH7 +9xV2Hywpnp2n48A22wiIQ7/BFZxtVM7qFm+F0FhPc3Es0azA7EOInMo04nD5XLZj +tsQ1PH1MXTbpcVEUns39AYeRSXsc14dtr7j1ur4a1UCZZAINMcgPQySzln3g/0qa +CIVI1R8rXajnuIZpQ8q1cXTjUb3JUr+rrx7dbL33b9Z3jMWXhsNRXbTzVLUvpYzv +40JVe4woKdOKzFrq4dYw+5I4ypqi8lYvudHj97wMcvh/EtMmPw0Z4Mws7NlO8nuL ++CM2TzojLdv3WSmyiBVNZXDahjF0RU8KfYX9o5kYBixTua0ylrXBWk+PQcEPdrDv +HwK49ag= +-----END CERTIFICATE----- diff --git a/tests/resources/cert/test-cases/ext-client.key b/tests/resources/cert/test-cases/ext-client.key new file mode 100644 index 00000000..238efb47 --- /dev/null +++ b/tests/resources/cert/test-cases/ext-client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDAAd8W8PRSiaEH +90LA+hK2xF7FUwFsqsgW66HJcKCtY/CqlICri9CNfpC8k3bvBzAbE0iIiVyHFvxP +mxwDy8QxMvZF8lEZQFkYZOyz68J9t3LYWzjQZLoo9kf9zwsd2njNlvnx+4oUj3pO +dfFtzbtfL7WKuZmkOmkljc8OpSxQGqOm4CHqVVtvPK963n3EgzujEwxppnQN3moh +3woEuHsL08TO7zU7zJgx2D9rpy3O3tToKsoWkElFxFFUumV5iuzzXDC+v0OSviu4 +ue3CgzglemtpU9Vr2vXMWkLjHGmXzR3PLRmmZlCRUzWZsYsejdzFR77+Vdau6BNL +kj26ziBnAgMBAAECggEAGFiEZt5xlFTy0ABC/MDDW7JQM/lU6RL0i6zKrFtKvFbm +PrLVZ9WywXuGsv/dvkGCkNmj7PrBz+KTES2hN+f/6J8bc/4ILh6nIr01TZD7bcEI +37Ryt+sgYa5CAvAxlXz3Vqt/O3bCE4j9IJ1lhJ89VBHyv8l4g5w2azZtG+ByBcv1 +WW9ZlbprKwTuVo89l0ZzAh9kz98NnATW12JxhxxBaS0PVLnOnjGVt7OZ5VD8psIa +4SS5MrPrLBTn6KJR/OBZib2NzQxMZQpnAB9omOp2yiYC2UqT3HQxjfRD2v03M98h +WHXu/eTe0GE97G+bw6Na54jOSBMpOs8B6fVYvZXZGQKBgQDi2/NdEaf4rhQ7uT8C ++XzV7Nk+eNkNZBWAclO9pTcArxN2XowyPZAM2q3mzWU9+gJt5tcSSiC9czPiQxTX +FiXYngnN4hXIyyfl4HDUmb36Nvpi/Ors/Wh2svIB4V3oLNcZHkWXCxQOsNxaTdsM +KM1obXDySUDeMl4p3Ea4WQCeKQKBgQDYq9mZ72WyO6iTxSXeuZZJmSw8bSyB8MPz +vL3B74JIj1o2UuZ8Ht7BaHLSTDEQ4TTkECw6en2wvUol9TXFCzsGig9zq0p7tXQB +CXTD7/yDbbFEe4CWreW/+AX1z0Zx+xs8kmg+SZrt8i/RH97OwKB0gCS0o/2yaswC +7GDX8QF8DwKBgQCHh6IHuxGfttgtqgkkDMrwvxQ2h1oc7usNlr/Cr96BURcOg4O8 +TB0wIMD9/z153k/vOpbIvXJ73ERl62+a5AduN1RiJKyhDgXjBshBqyvdPVUvKCxt +syLirxt31h0VJRzIS9aFWz/7Wtv6M0MnK4Uz2xY8GVlgpbSty4SQg9OjqQKBgC7R +S06g1PejPnTXp6wtq9SxXUadTH2zWZQEF3idWSh2mUaduSHexcFC4XShdASytOwG +tpfYOeqDrE7xYjH2kEWEdXxH6es7NRq4QVvJMmXvwNsMWKe2YauOWzNXG2CroqH+ +/LlgzDJYH47vdQR1yPYDbmr9+Gah/v0uuGpQsEJvAoGBAIuaPY4GiLQXYqK3zOnB +6aa+mI6VsCRnivhEcaqhlfrJ5Dzjlq0Lw/7qNyCJjEZtNkHlWpetU2ELTk3V+i+X +AjYTnP4xraztUbTQgXvAiG3QoT6XGsdlwsUbKGW883zntH9yL98j8hc5MKhkzBic +nUAs5QNA0+lPOfSXQSJsrV/F +-----END PRIVATE KEY----- diff --git a/tests/resources/cert/test-cases/ext-nobody.crt b/tests/resources/cert/test-cases/ext-nobody.crt new file mode 100644 index 00000000..460f7f33 --- /dev/null +++ b/tests/resources/cert/test-cases/ext-nobody.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDMDCCAhigAwIBAgIUbH/ZCmsSxwHnCBIvfudKD+ZF8GMwDQYJKoZIhvcNAQEL +BQAwIzEhMB8GA1UEAwwYRnJlZURTeCBUZXN0IEV4dGVybmFsIENBMCAXDTI2MDYw +ODEyMTMxNFoYDzIxMjYwNTE1MTIxMzE0WjA7MRMwEQYKCZImiZPyLGQBGRYDYmFy +MRMwEQYKCZImiZPyLGQBGRYDZm9vMQ8wDQYDVQQDDAZub2JvZHkwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3wUSZvYdi+flLkDc6mE0UtYG8fnSD4aOp +VXOUFSOBd/bZKPaokpVaOuMpcRUEfxE9TWElZ0rPs8OsVmWSf360TwxpR6Nu6lja +ikAkVeReN5tN9ddwog2WV7oY9sZyNncXbhVOQ28CXcQ3rlqqbfLwuTjrRJCJXkG5 +JBp2+PH73UMT+FK7OveDEOTcV17JCb6cqKvXobDuALhay8R6SLV1B3YVMaOQvfeo +naZ+JjoPkcI3O3fVyXFkFcUYHuT+UcxNjfgWmTaZJyPYkYFOR/jwrdrQ4K8FMmV/ +6lahWTDaw5+wzWrpPc2mUVd5sjoBu1EQljlhyjouqbd0pwIr1c7TAgMBAAGjQjBA +MB0GA1UdDgQWBBTrtwhg6D+6ZEv6i6SO3L4H+vSpUzAfBgNVHSMEGDAWgBQubQ81 +asMtcYpk5NBlZEdezJyVXjANBgkqhkiG9w0BAQsFAAOCAQEAANTe32NeCc/gS9kq +k1L2exzNJmk6t11LiLeaMAglqUeDkklLdY9qcxfhX5exbIcmEFcC8/m9uvW8Kj8b +bVuoMLlxce6SQbbNuNSEynun9eSBn5+vAKfFXn6Xr7SNpy51xeD/0wJPYOs2F6Uk +K6JrtPfKxpPYpXJZRF+bi4kNV7kvIUvo9lqJFyNsKC2OhcluRBY3kaO/j90aAszf +AoiZ1FCSIP3quH0MgKbAj8BAa+YYRM3k+HX9tsiD94rQ7Cw5RArqBxodzPp3aHY8 +yC2o3N6A1MOAS//Q6zr3MP9OXVYnUjtSrU+udxUD3bs8E6on5HkReUTiaj7MfNhu +6QELOA== +-----END CERTIFICATE----- diff --git a/tests/resources/cert/test-cases/ext-nobody.key b/tests/resources/cert/test-cases/ext-nobody.key new file mode 100644 index 00000000..224ba24f --- /dev/null +++ b/tests/resources/cert/test-cases/ext-nobody.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC3wUSZvYdi+flL +kDc6mE0UtYG8fnSD4aOpVXOUFSOBd/bZKPaokpVaOuMpcRUEfxE9TWElZ0rPs8Os +VmWSf360TwxpR6Nu6ljaikAkVeReN5tN9ddwog2WV7oY9sZyNncXbhVOQ28CXcQ3 +rlqqbfLwuTjrRJCJXkG5JBp2+PH73UMT+FK7OveDEOTcV17JCb6cqKvXobDuALha +y8R6SLV1B3YVMaOQvfeonaZ+JjoPkcI3O3fVyXFkFcUYHuT+UcxNjfgWmTaZJyPY +kYFOR/jwrdrQ4K8FMmV/6lahWTDaw5+wzWrpPc2mUVd5sjoBu1EQljlhyjouqbd0 +pwIr1c7TAgMBAAECggEAJ5Cek7fCUeZe7g00RxKJ7j6Sm6Jitg68bXZvt1/B0Fuj +M+jlCzDcpZvuU3r8uoYdkSR7GVGfQw+CvzWvExcOkgY/Nt/s6bHdxdhkrS4tSLgS +YXvgkmjiIg/rivp/ihH+HHZgcgSE/25vFEofTXj1CS3oeoe900YPQqzqYdmCSOdi +tdSOMCqHnqha1BfhRu5apZS9JYfdzukbbMKNtx2fW2AOhwkUBW05oAr3jxEAdDJE +Xtw6tTk9YHO1SglFC0vEZEpbX4jaKCWYIxmlCMpDLRvEbongksqi+9LpPUhj+scr +TbQrA2/IqMivFyMEWTfZYYOUmKJGr2XFNGMdQxhhwQKBgQDnMTjYrdROXSZLYdeX +SgfQo343ThRAKNlRN/zkTz0Xq6UmtpBMsLqL6Ugyeh4eaEGAa1liqY32YrvxzK1u +AMSLZ0l5EJaRpoILk4V0kIAlY0RqTCea4wmW4OPYfTvRyglMwJwvtkNKvSKvIh8T +Vz+ycRF5ZU5+6Pg2FiRCdcp8cwKBgQDLePU2cVA9w1DWm8bQ7u5z7yBa6oVFQHc9 +OwvJlmciL6RruUYxjE1iZkO8XWy2otHC9tmw267xUGS7u6ih/puAJBmBjBMfbHQ3 +JxlKeGHskDTtWN5SZwEGq9XfEdD4nXhElyAS/Ftnx0l5Oxu22nJIqQsyh7l7DlDs +22AfVUgsIQKBgQClwt/8U54uVZFYaR2XxqeVyzN62cuhOOif2CHFXJ7z8sil2i3a +HriSCkAOmQoxSRT1y4I6QFGd/6q7ssICZiCFxxeh6ufaJGWHgU0lh1mp9OOfx1x6 +LCC7AiG7Hgee4loKousZNnhHBRbyNOfNCTiNa45Y2O8QBV+5/+QdlrEIywKBgHne +X9iU8/+aPY/cy68WfMH5psJtlxcMbp7A/+Vk7S6/pFZVKSLCKxNVtxpaRqP5T3Pb +0DUqz1R/12XOF0m3qsGMXa6HDGkU12K1S2OcSOKc2OaUBM0MHsQ1JasvC5/tCTzj +23Ujq0e0SGCRM59IpYy5mxhPzJtUzsme96qstMahAoGABIhlbaSDz4y9W+hfxraX +PVmxzuhpq0WT6an+VbruSYi4JQ4+O9CGkEqnkTGeb4VKDwrLVFsP2fRbisGA/tEd +99/PpVy+2EXn8Layennxemm+UMtZbVCYLUwn1sZhBOTh1zrV5iuEBTEod1VfNeAj +G4NX6sCsBDQ6zrFEFCWO438= +-----END PRIVATE KEY----- diff --git a/tests/resources/cert/test-cases/generate-external-certs.sh b/tests/resources/cert/test-cases/generate-external-certs.sh new file mode 100755 index 00000000..e1d29215 --- /dev/null +++ b/tests/resources/cert/test-cases/generate-external-certs.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# +# Regenerates the certs used in test fixtures. A throwaway CA plus two client certificates. +# +# The CA key is intentionally discarded. Each run regenerates the whole set. +# +# Add a fixture by calling gen() with an output name and an X.509 subject, e.g.: + +# gen ext-client "/DC=bar/DC=foo/CN=extuser" +# +# The subject's RDNs are reversed into an LDAP DN by SubjectDnCredentialMapper. +# +set -euo pipefail +cd "$(dirname "$0")" + +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout ext-ca.key -out ext-ca.crt -days 36500 \ + -subj "/CN=FreeDSx Test External CA" + +gen() { + local name="$1" subject="$2" + openssl req -newkey rsa:2048 -nodes -keyout "${name}.key" -out "${name}.csr" -subj "$subject" + openssl x509 -req -in "${name}.csr" -CA ext-ca.crt -CAkey ext-ca.key -CAcreateserial \ + -days 36500 -out "${name}.crt" + rm -f "${name}.csr" +} + +# Subjects reverse (via SubjectDnCredentialMapper) to cn=extuser,dc=foo,dc=bar (a seeded entry) +# and cn=nobody,dc=foo,dc=bar (which is not seeded). +gen ext-client "/DC=bar/DC=foo/CN=extuser" +gen ext-nobody "/DC=bar/DC=foo/CN=nobody" + +rm -f ext-ca.key ext-ca.srl diff --git a/tests/support/LdapServerCommand.php b/tests/support/LdapServerCommand.php index fbb0ed9b..980fa5ce 100644 --- a/tests/support/LdapServerCommand.php +++ b/tests/support/LdapServerCommand.php @@ -35,6 +35,8 @@ final class LdapServerCommand extends Command private const SSL_CERT = __DIR__ . '/../resources/cert/slapd.crt'; + private const EXTERNAL_CA_CERT = __DIR__ . '/../resources/cert/test-cases/ext-ca.crt'; + private const VALID_STORAGE = ['memory', 'json', 'sqlite', 'mysql']; protected function configure(): void @@ -103,6 +105,12 @@ protected function configure(): void InputOption::VALUE_NONE, 'Allow anonymous bind', ) + ->addOption( + 'external', + null, + InputOption::VALUE_NONE, + 'Enable SASL EXTERNAL with client-certificate validation (implies TLS)', + ) ->addOption( 'seed', null, @@ -143,6 +151,7 @@ protected function execute( $storageType = $this->getStringOption($input, 'storage'); $entryCount = (int) $this->getStringOption($input, 'entries'); $sasl = $input->getOption('sasl') === true; + $external = $input->getOption('external') === true; $allowAnonymous = $input->getOption('allow-anonymous') === true; $seedFile = $this->getStringOption($input, 'seed'); $changesFile = $this->getStringOption($input, 'changes'); @@ -163,6 +172,18 @@ protected function execute( $entries = $sasl ? $this->buildSaslEntries() : $this->buildDefaultEntries(); + if ($external) { + // Subject "/DC=bar/DC=foo/CN=extuser" maps (reversed) to this DN via SubjectDnCredentialMapper. + $entries[] = Entry::fromArray( + 'cn=extuser,dc=foo,dc=bar', + [ + 'cn' => 'extuser', + 'objectClass' => 'inetOrgPerson', + 'sn' => 'External', + ], + ); + } + for ($i = 1; $i <= $entryCount; $i++) { $entries[] = Entry::fromArray( "cn=entry-{$i},dc=foo,dc=bar", @@ -215,6 +236,13 @@ protected function execute( ); } + if ($external) { + $options + ->setSslValidateCert(true) + ->setSslCaCert(self::EXTERNAL_CA_CERT) + ->setSaslMechanisms(ServerOptions::SASL_EXTERNAL); + } + $server->useStorage($storage); if ($seedFile !== '') { diff --git a/tests/unit/Protocol/Authorization/AuthzIdResolverTest.php b/tests/unit/Protocol/Authorization/AuthzIdResolverTest.php new file mode 100644 index 00000000..6ce60b71 --- /dev/null +++ b/tests/unit/Protocol/Authorization/AuthzIdResolverTest.php @@ -0,0 +1,216 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Protocol\Authorization; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Protocol\Authorization\AuthzId; +use FreeDSx\Ldap\Protocol\Authorization\AuthzIdResolver; +use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Logging\EventLogger; +use FreeDSx\Ldap\Server\Logging\EventLogPolicy; +use FreeDSx\Ldap\Server\Token\AnonToken; +use FreeDSx\Ldap\Server\Token\AuthenticatedTokenInterface; +use FreeDSx\Ldap\Server\Token\BindToken; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Tests\Support\FreeDSx\Ldap\Logging\RecordingLogger; + +final class AuthzIdResolverTest extends TestCase +{ + private const ADMIN_DN = 'cn=admin,dc=example,dc=com'; + + private const PROXIED_DN = 'cn=alice,dc=example,dc=com'; + + private AccessControlInterface&MockObject $accessControl; + + private LdapBackendInterface&MockObject $backend; + + private BindNameResolverInterface&MockObject $identityResolver; + + private AuthzIdResolver $subject; + + private BindToken $boundToken; + + protected function setUp(): void + { + $this->accessControl = $this->createMock(AccessControlInterface::class); + $this->backend = $this->createMock(LdapBackendInterface::class); + $this->identityResolver = $this->createMock(BindNameResolverInterface::class); + $this->subject = new AuthzIdResolver( + $this->accessControl, + $this->backend, + $this->identityResolver, + new EventLogger( + new RecordingLogger(), + EventLogPolicy::all(), + ), + ); + $this->boundToken = new BindToken( + self::ADMIN_DN, + 'secret', + new Dn(self::ADMIN_DN), + ); + } + + public function test_it_resolves_a_dn_authz_id_via_the_backend(): void + { + $this->backend + ->method('get') + ->willReturn(new Entry(new Dn(self::PROXIED_DN))); + + $entry = $this->subject->resolve(AuthzId::fromString('dn:' . self::PROXIED_DN)); + + self::assertSame( + self::PROXIED_DN, + $entry?->getDn()->toString(), + ); + } + + public function test_it_resolves_a_username_authz_id_via_the_identity_resolver(): void + { + $this->identityResolver + ->method('resolve') + ->willReturn(new Entry(new Dn(self::PROXIED_DN))); + + $entry = $this->subject->resolve(AuthzId::fromString('u:alice')); + + self::assertSame( + self::PROXIED_DN, + $entry?->getDn()->toString(), + ); + } + + public function test_it_resolves_an_anonymous_authz_id_to_null(): void + { + self::assertNull($this->subject->resolve(AuthzId::fromString(''))); + } + + public function test_it_resolves_to_null_when_the_backend_throws(): void + { + $this->backend + ->method('get') + ->willThrowException(new OperationException( + 'No such object.', + ResultCode::NO_SUCH_OBJECT, + )); + + self::assertNull($this->subject->resolve(AuthzId::fromString('dn:' . self::PROXIED_DN))); + } + + public function test_assume_returns_the_proxied_token_with_the_authorizing_dn(): void + { + $this->accessControl + ->method('mayUseControl') + ->willReturn(true); + $this->backend + ->method('get') + ->willReturn(new Entry(new Dn(self::PROXIED_DN))); + + $token = $this->subject->assume( + $this->boundToken, + AuthzId::fromString('dn:' . self::PROXIED_DN), + ); + + if (!$token instanceof AuthenticatedTokenInterface) { + self::fail('Expected an authenticated token.'); + } + self::assertSame( + self::PROXIED_DN, + $token->getResolvedDn()->toString(), + ); + self::assertSame( + self::ADMIN_DN, + $token->getAuthorizingDn()?->toString(), + ); + } + + public function test_assume_anonymous_returns_an_anonymous_token(): void + { + $this->accessControl + ->method('mayUseControl') + ->willReturn(true); + + $token = $this->subject->assume( + $this->boundToken, + AuthzId::fromString(''), + ); + + self::assertInstanceOf( + AnonToken::class, + $token, + ); + } + + public function test_assume_is_denied_without_the_proxy_grant(): void + { + $this->accessControl + ->method('mayUseControl') + ->willReturn(false); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::AUTHORIZATION_DENIED); + + $this->subject->assume( + $this->boundToken, + AuthzId::fromString('dn:' . self::PROXIED_DN), + ); + } + + public function test_assume_is_denied_when_the_target_is_not_found(): void + { + $this->accessControl + ->method('mayUseControl') + ->willReturn(true); + $this->backend + ->method('get') + ->willReturn(null); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::AUTHORIZATION_DENIED); + + $this->subject->assume( + $this->boundToken, + AuthzId::fromString('dn:' . self::PROXIED_DN), + ); + } + + public function test_assume_is_denied_when_the_target_is_not_authorized(): void + { + $this->accessControl + ->method('mayUseControl') + ->willReturn(true); + $this->backend + ->method('get') + ->willReturn(new Entry(new Dn(self::PROXIED_DN))); + $this->accessControl + ->method('authorizeControl') + ->willThrowException(new OperationException( + 'Insufficient access rights.', + ResultCode::INSUFFICIENT_ACCESS_RIGHTS, + )); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::AUTHORIZATION_DENIED); + + $this->subject->assume( + $this->boundToken, + AuthzId::fromString('dn:' . self::PROXIED_DN), + ); + } +} diff --git a/tests/unit/Protocol/Authorization/AuthzIdTest.php b/tests/unit/Protocol/Authorization/AuthzIdTest.php new file mode 100644 index 00000000..15e8b6d5 --- /dev/null +++ b/tests/unit/Protocol/Authorization/AuthzIdTest.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Protocol\Authorization; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Exception\InvalidArgumentException; +use FreeDSx\Ldap\Protocol\Authorization\AuthzId; +use FreeDSx\Ldap\Protocol\Authorization\AuthzIdType; +use PHPUnit\Framework\TestCase; + +final class AuthzIdTest extends TestCase +{ + public function test_it_parses_the_dn_form(): void + { + $authzId = AuthzId::fromString('dn:cn=foo,dc=example,dc=com'); + + self::assertTrue($authzId->isType(AuthzIdType::Dn)); + self::assertSame( + 'cn=foo,dc=example,dc=com', + $authzId->getValue(), + ); + self::assertSame( + 'dn:cn=foo,dc=example,dc=com', + $authzId->toString(), + ); + } + + public function test_it_parses_the_username_form(): void + { + $authzId = AuthzId::fromString('u:bob'); + + self::assertTrue($authzId->isType(AuthzIdType::Username)); + self::assertSame( + 'bob', + $authzId->getValue(), + ); + self::assertSame( + 'u:bob', + $authzId->toString(), + ); + } + + public function test_an_empty_string_is_the_anonymous_identity(): void + { + $authzId = AuthzId::fromString(''); + + self::assertTrue($authzId->isType(AuthzIdType::Anonymous)); + self::assertSame( + '', + $authzId->getValue(), + ); + self::assertSame( + '', + $authzId->toString(), + ); + } + + public function test_it_rejects_an_unrecognized_form(): void + { + $this->expectException(InvalidArgumentException::class); + + AuthzId::fromString('bob'); + } + + public function test_it_builds_from_a_dn(): void + { + $authzId = AuthzId::fromDn(new Dn('cn=foo,dc=example,dc=com')); + + self::assertTrue($authzId->isType(AuthzIdType::Dn)); + self::assertSame( + 'dn:cn=foo,dc=example,dc=com', + $authzId->toString(), + ); + } + + public function test_it_builds_from_a_username(): void + { + $authzId = AuthzId::fromUsername('bob'); + + self::assertTrue($authzId->isType(AuthzIdType::Username)); + self::assertSame( + 'bob', + $authzId->getValue(), + ); + self::assertSame( + 'u:bob', + $authzId->toString(), + ); + } +} diff --git a/tests/unit/Protocol/Authorization/DispatchAuthorizerTest.php b/tests/unit/Protocol/Authorization/DispatchAuthorizerTest.php index 7957c01e..76b9ae71 100644 --- a/tests/unit/Protocol/Authorization/DispatchAuthorizerTest.php +++ b/tests/unit/Protocol/Authorization/DispatchAuthorizerTest.php @@ -22,6 +22,7 @@ use FreeDSx\Ldap\Operation\Request\ExtendedRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\Authorization\DispatchAuthorizer; +use FreeDSx\Ldap\Protocol\Authorization\AuthzIdResolver; use FreeDSx\Ldap\Protocol\Authorization\ProxiedAuthorizationResolver; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\ServerAuthorization; @@ -61,10 +62,12 @@ protected function setUp(): void $this->authorizer, new PasswordResetGate(), new ProxiedAuthorizationResolver( - $this->accessControl, - $this->backend, - $this->createMock(BindNameResolverInterface::class), - new EventLogger(null), + new AuthzIdResolver( + $this->accessControl, + $this->backend, + $this->createMock(BindNameResolverInterface::class), + new EventLogger(null), + ), ), ); } diff --git a/tests/unit/Protocol/Authorization/ProxiedAuthorizationResolverTest.php b/tests/unit/Protocol/Authorization/ProxiedAuthorizationResolverTest.php index 10e2186e..b0381484 100644 --- a/tests/unit/Protocol/Authorization/ProxiedAuthorizationResolverTest.php +++ b/tests/unit/Protocol/Authorization/ProxiedAuthorizationResolverTest.php @@ -24,6 +24,7 @@ use FreeDSx\Ldap\Operation\Request\ExtendedRequest; use FreeDSx\Ldap\Operation\Request\RequestInterface; use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Protocol\Authorization\AuthzIdResolver; use FreeDSx\Ldap\Protocol\Authorization\ProxiedAuthorizationResolver; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; @@ -64,12 +65,14 @@ protected function setUp(): void $this->identityResolver = $this->createMock(BindNameResolverInterface::class); $this->recordingLogger = new RecordingLogger(); $this->subject = new ProxiedAuthorizationResolver( - $this->accessControl, - $this->backend, - $this->identityResolver, - new EventLogger( - $this->recordingLogger, - EventLogPolicy::all(), + new AuthzIdResolver( + $this->accessControl, + $this->backend, + $this->identityResolver, + new EventLogger( + $this->recordingLogger, + EventLogPolicy::all(), + ), ), ); $this->boundToken = new BindToken( diff --git a/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/ExternalMechanismOptionsBuilderTest.php b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/ExternalMechanismOptionsBuilderTest.php new file mode 100644 index 00000000..4fe8b90f --- /dev/null +++ b/tests/unit/Protocol/Bind/Sasl/OptionsBuilder/ExternalMechanismOptionsBuilderTest.php @@ -0,0 +1,259 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder; + +use Closure; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Protocol\Authorization\AuthzId; +use FreeDSx\Ldap\Protocol\Authorization\AuthzIdResolver; +use FreeDSx\Ldap\Protocol\Bind\Sasl\OptionsBuilder\ExternalMechanismOptionsBuilder; +use FreeDSx\Ldap\Protocol\Queue\ServerQueue; +use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Logging\EventLogger; +use FreeDSx\Ldap\Server\Logging\EventLogPolicy; +use FreeDSx\Ldap\Server\Sasl\External\ExternalCredentialMapperInterface; +use FreeDSx\Ldap\ServerOptions; +use FreeDSx\Sasl\Mechanism\MechanismName; +use FreeDSx\Sasl\Options\ExternalOptions; +use FreeDSx\Socket\Tls\Certificate; +use OpenSSLAsymmetricKey; +use OpenSSLCertificate; +use OpenSSLCertificateSigningRequest; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Tests\Support\FreeDSx\Ldap\Logging\RecordingLogger; + +final class ExternalMechanismOptionsBuilderTest extends TestCase +{ + private const CERT_DN = 'cn=client,dc=example,dc=com'; + + private const TARGET_DN = 'cn=service,dc=example,dc=com'; + + private AccessControlInterface&MockObject $accessControl; + + private LdapBackendInterface&MockObject $backend; + + private AuthzIdResolver $authzIdResolver; + + protected function setUp(): void + { + $this->accessControl = $this->createMock(AccessControlInterface::class); + $this->backend = $this->createMock(LdapBackendInterface::class); + $this->authzIdResolver = new AuthzIdResolver( + $this->accessControl, + $this->backend, + $this->createMock(BindNameResolverInterface::class), + new EventLogger( + new RecordingLogger(), + EventLogPolicy::all(), + ), + ); + } + + public function test_it_requires_an_encrypted_connection(): void + { + $validate = $this->validateClosure($this->builder( + encrypted: false, + validateCert: true, + certificate: null, + mapped: null, + )); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::CONFIDENTIALITY_REQUIRED); + + $validate(null); + } + + public function test_it_requires_certificate_validation_to_be_enabled(): void + { + $validate = $this->validateClosure($this->builder( + encrypted: true, + validateCert: false, + certificate: null, + mapped: null, + )); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::INAPPROPRIATE_AUTHENTICATION); + + $validate(null); + } + + public function test_it_requires_a_presented_certificate(): void + { + $validate = $this->validateClosure($this->builder( + encrypted: true, + validateCert: true, + certificate: null, + mapped: null, + )); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::INAPPROPRIATE_AUTHENTICATION); + + $validate(null); + } + + public function test_it_fails_when_the_mapper_rejects_the_certificate(): void + { + $validate = $this->validateClosure($this->builder( + encrypted: true, + validateCert: true, + certificate: $this->certificate(), + mapped: null, + )); + + self::assertFalse($validate(null)); + } + + public function test_it_resolves_the_certificate_identity_when_no_authz_id_is_requested(): void + { + $this->backend + ->method('get') + ->willReturn(new Entry(new Dn(self::CERT_DN))); + + $builder = $this->builder( + encrypted: true, + validateCert: true, + certificate: $this->certificate(), + mapped: AuthzId::fromDn(new Dn(self::CERT_DN)), + ); + + self::assertTrue($this->validateClosure($builder)(null)); + self::assertSame( + self::CERT_DN, + $builder->getResolvedDn()?->toString(), + ); + self::assertNull($builder->getAuthorizingDn()); + } + + public function test_it_assumes_a_requested_authz_id_recording_the_authorizing_dn(): void + { + $this->accessControl + ->method('mayUseControl') + ->willReturn(true); + $this->backend + ->method('get') + ->willReturnCallback(fn(Dn $dn): Entry => new Entry($dn)); + + $builder = $this->builder( + encrypted: true, + validateCert: true, + certificate: $this->certificate(), + mapped: AuthzId::fromDn(new Dn(self::CERT_DN)), + ); + + self::assertTrue($this->validateClosure($builder)('dn:' . self::TARGET_DN)); + self::assertSame( + self::TARGET_DN, + $builder->getResolvedDn()?->toString(), + ); + self::assertSame( + self::CERT_DN, + $builder->getAuthorizingDn()?->toString(), + ); + } + + public function test_it_fails_on_a_malformed_authz_id(): void + { + $this->backend + ->method('get') + ->willReturn(new Entry(new Dn(self::CERT_DN))); + + $builder = $this->builder( + encrypted: true, + validateCert: true, + certificate: $this->certificate(), + mapped: AuthzId::fromDn(new Dn(self::CERT_DN)), + ); + + self::assertFalse($this->validateClosure($builder)('not-an-authzid')); + } + + private function builder( + bool $encrypted, + bool $validateCert, + ?Certificate $certificate, + ?AuthzId $mapped, + ): ExternalMechanismOptionsBuilder { + $queue = $this->createMock(ServerQueue::class); + $queue->method('isEncrypted') + ->willReturn($encrypted); + $queue->method('peerCertificate') + ->willReturn($certificate); + + $mapper = $this->createMock(ExternalCredentialMapperInterface::class); + $mapper->method('map') + ->willReturn($mapped); + + return new ExternalMechanismOptionsBuilder( + $queue, + (new ServerOptions())->setSslValidateCert($validateCert), + $mapper, + $this->authzIdResolver, + ); + } + + /** + * @return Closure(?string): bool + */ + private function validateClosure(ExternalMechanismOptionsBuilder $builder): Closure + { + $options = $builder->buildOptions( + null, + MechanismName::EXTERNAL, + ); + if (!$options instanceof ExternalOptions) { + self::fail('Expected ExternalOptions to be built.'); + } + + $validate = $options->getValidate(); + if ($validate === null) { + self::fail('Expected a validate closure to be set.'); + } + + return $validate; + } + + private function certificate(): Certificate + { + $key = openssl_pkey_new(['private_key_bits' => 2048]); + if (!$key instanceof OpenSSLAsymmetricKey) { + self::fail('Failed to generate a private key.'); + } + + $csr = openssl_csr_new(['commonName' => 'client'], $key); + if (!$csr instanceof OpenSSLCertificateSigningRequest || !$key instanceof OpenSSLAsymmetricKey) { + self::fail('Failed to generate a certificate signing request.'); + } + + $x509 = openssl_csr_sign($csr, null, $key, 1); + if (!$x509 instanceof OpenSSLCertificate) { + self::fail('Failed to sign the certificate.'); + } + + $certificate = Certificate::fromX509($x509); + if ($certificate === null) { + self::fail('Failed to parse the generated certificate.'); + } + + return $certificate; + } +} diff --git a/tests/unit/Server/Sasl/External/SubjectDnCredentialMapperTest.php b/tests/unit/Server/Sasl/External/SubjectDnCredentialMapperTest.php new file mode 100644 index 00000000..93d63db8 --- /dev/null +++ b/tests/unit/Server/Sasl/External/SubjectDnCredentialMapperTest.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Sasl\External; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Protocol\Authorization\AuthzIdType; +use FreeDSx\Ldap\Server\Sasl\External\SubjectDnCredentialMapper; +use FreeDSx\Socket\Tls\Certificate; +use OpenSSLCertificate; +use PHPUnit\Framework\TestCase; + +final class SubjectDnCredentialMapperTest extends TestCase +{ + /** + * Throwaway self-signed cert: openssl req -x509 -subj "/C=US/O=Acme/CN=foo". + */ + private const COCN_PEM = <<<'PEM' + -----BEGIN CERTIFICATE----- + MIIDNTCCAh2gAwIBAgIUW3+8Qyj/TsLNxg/Pdob8sf+1ahMwDQYJKoZIhvcNAQEL + BQAwKjELMAkGA1UEBhMCVVMxDTALBgNVBAoMBEFjbWUxDDAKBgNVBAMMA2ZvbzAe + Fw0yNjA2MDcyMzAwNThaFw0yNjA2MDgyMzAwNThaMCoxCzAJBgNVBAYTAlVTMQ0w + CwYDVQQKDARBY21lMQwwCgYDVQQDDANmb28wggEiMA0GCSqGSIb3DQEBAQUAA4IB + DwAwggEKAoIBAQDpS41EGltCjOdAeb46+NOcmP8TfitevD0msXl4aVoilhZPcr17 + go4VDGVVovHb9Ji3YtPs56rmEp/YxRTjy6uNvu4Uig2g1Iis3JjAEcz0NjiZPs4E + 6NENAvk4Y6/HvjK50w3yokUuZCGXuZfnDlR9PUr584/IylM3F3rjlCu5Cr05Dx5a + /ci70zD7gJx+qFCc7XEy97fRr4qOFtIPkC+WSub7Oow0az91I9zuulUkGhFWQbdQ + agOB3tZwod2ODW5MCiFCkssh1mG5mL8mUAbUrMspdQIU9TC3TL7my38S2bK0utr2 + +593aANAvN5guf2FU+10DcFYIpF115lpVOOLAgMBAAGjUzBRMB0GA1UdDgQWBBRs + QIbfOVWmdTjl5vMYM8nYSiNeGTAfBgNVHSMEGDAWgBRsQIbfOVWmdTjl5vMYM8nY + SiNeGTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQDeXWPNUuqd + EeUJeeCRZoKECOaGeZWoQuAeR5QffVjaCo8hS6JMD22p12HDz86ztLtilxgm43Cm + l6RBL6QHCsDD5fReG8j7a8qRGx40aARzr6Z0orhl1/+TAvqJ21q/3f/qrJT/DFTi + k2sIE5nvoYYDqdEzh8TgnK0VIKF6YqzK0Ix0NutvTUuHtBDiwFY7D++HJYsdtIEh + NlC3t0UcRFnz+qrN7xYGcry1IDUfVcVOKJ6ISquqfx9/wMiw92CFQtJ7onridIkt + BLiKOmEoCGr4yeu7QTR/N+DTRnyAqSHflCfy5V+WhKq0htIdI2K7ktyn91kZGnmX + WpavO+u42dau + -----END CERTIFICATE----- + PEM; + + /** + * Throwaway self-signed cert: openssl req -x509 -subj "/DC=com/DC=example/CN=bar". + */ + private const DC_PEM = <<<'PEM' + -----BEGIN CERTIFICATE----- + MIIDWTCCAkGgAwIBAgIULi/f0ddOMTGz2lCfRHhhnW0jgJAwDQYJKoZIhvcNAQEL + BQAwPDETMBEGCgmSJomT8ixkARkWA2NvbTEXMBUGCgmSJomT8ixkARkWB2V4YW1w + bGUxDDAKBgNVBAMMA2JhcjAeFw0yNjA2MDcyMzAwNThaFw0yNjA2MDgyMzAwNTha + MDwxEzARBgoJkiaJk/IsZAEZFgNjb20xFzAVBgoJkiaJk/IsZAEZFgdleGFtcGxl + MQwwCgYDVQQDDANiYXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDB + bzaeSLulCbgl7eqdBx/o5C11L0JScSv1NuDK3h+6QJ65tPHzEeIM/K5ZVzkw5HlP + 3rcaqYQtSAy5pYMUttoj7kzzKRGRg97d475qPFm9wtMbTTHfPzDOSuvbQdhzWcYT + Upw3sgYhFG9GTUveAc5wp7KhaQLnCZDWbO+Gwt0UTEoO/nu4tfrxdUWh2uqoL2uH + mVZ7J3mYeLQQV5H1T3FTbPM0lV66H0QZwnvCa8rZ8MKqoxDqgvP6C0l64ikcM0Rl + MS187XKksHsgDdmC9Qs1k5l5X0Joy3GXzRHSzaz1p0b2v3n9Xb1oZE2KQuj729SC + Pln/8Kmf9SuNbzKfdkmdAgMBAAGjUzBRMB0GA1UdDgQWBBSQy0YakBKMSST+VgLc + jZHaib5L4DAfBgNVHSMEGDAWgBSQy0YakBKMSST+VgLcjZHaib5L4DAPBgNVHRMB + Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQC0K2XKK1sAvzHreL3ERpjGQ/lT + gwU7/4QRgffoZqjppF7LwhqO5D1DNTj9bTiHnobQziZpZtIdj+7h9WsBfzI5RHVZ + QGL3EwPnnbf6mNXCLbJ2jvqmkDGd+wg0v4Q9gta24f0Y8QS9QV2b8nCAN4sTEw9P + XMudQ/ezXFsIDS92VXy+ZdGXJmgPN19Kg0F6mtWziV16dopmNQK1LDxWhIVWIhBW + /Hwtd8gmwlovAVKCQRjvAmlS6Y6hbijcuUFUs7ux5ablyuZ8CfZ1yU9HDWy4yOrl + hMHSm/4AkHj2D7Y9F0iRHUlNj/jeXe42OsG9HibTTPkFic7z0WAglfwxqIZd + -----END CERTIFICATE----- + PEM; + + public function test_it_reverses_the_subject_into_an_ldap_dn(): void + { + $authzId = (new SubjectDnCredentialMapper())->map($this->certificate(self::COCN_PEM)); + + self::assertNotNull($authzId); + self::assertTrue($authzId->isType(AuthzIdType::Dn)); + self::assertSame( + (new Dn('cn=foo,o=acme,c=us'))->normalize()->toString(), + (new Dn($authzId->getValue()))->normalize()->toString(), + ); + } + + public function test_it_expands_multi_valued_components_like_dc(): void + { + $authzId = (new SubjectDnCredentialMapper())->map($this->certificate(self::DC_PEM)); + + self::assertNotNull($authzId); + self::assertSame( + (new Dn('cn=bar,dc=example,dc=com'))->normalize()->toString(), + (new Dn($authzId->getValue()))->normalize()->toString(), + ); + } + + private function certificate(string $pem): Certificate + { + $x509 = openssl_x509_read($pem); + if (!$x509 instanceof OpenSSLCertificate) { + self::fail('Failed to read the fixture certificate.'); + } + + $certificate = Certificate::fromX509($x509); + if ($certificate === null) { + self::fail('Failed to parse the fixture certificate.'); + } + + return $certificate; + } +} diff --git a/tests/unit/ServerOptionsTest.php b/tests/unit/ServerOptionsTest.php index 5985c7b4..00c3fa0b 100644 --- a/tests/unit/ServerOptionsTest.php +++ b/tests/unit/ServerOptionsTest.php @@ -22,6 +22,7 @@ use FreeDSx\Ldap\Server\PasswordPolicy\QualityCheck\PasswordQualityCheckerInterface; use FreeDSx\Ldap\Server\PasswordPolicy\Rules\PasswordQualityRules; use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; +use FreeDSx\Ldap\Server\Sasl\External\ExternalCredentialMapperInterface; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; @@ -70,6 +71,33 @@ public function test_it_can_set_supported_sasl_mechanisms(): void ); } + public function test_it_can_set_the_external_sasl_mechanism(): void + { + $this->subject->setSaslMechanisms(ServerOptions::SASL_EXTERNAL); + + self::assertSame( + [ServerOptions::SASL_EXTERNAL], + $this->subject->getSaslMechanisms(), + ); + } + + public function test_external_credential_mapper_is_null_by_default(): void + { + self::assertNull($this->subject->getExternalCredentialMapper()); + } + + public function test_it_can_set_an_external_credential_mapper(): void + { + $mapper = $this->createMock(ExternalCredentialMapperInterface::class); + + $this->subject->setExternalCredentialMapper($mapper); + + self::assertSame( + $mapper, + $this->subject->getExternalCredentialMapper(), + ); + } + public function test_it_throws_for_an_unsupported_sasl_mechanism(): void { self::expectException(InvalidArgumentException::class);