From a998a2660e46285d90fba6280b18a68b0b08fec4 Mon Sep 17 00:00:00 2001 From: William Date: Tue, 7 Jul 2026 14:54:03 +0200 Subject: [PATCH] feat!: consolidate optional send*/handle* parameters into ContextList (#12) send*/handle* methods on both wrapper classes had accumulated one positional/named optional parameter per protocol detail (relayState, sessionIndex, nameIdPolicyFormat, nameId, plus bool $validate / ?Entity $issuer duplicated across 8 call sites). Replace all of it with a single ContextList $context parameter: a typed, variadic collection of Context value objects (RelayState, SessionIndex, NameIdPolicyFormat, Validate, NameId, Attribute). Validate(Entity $issuer) merges the old validate/issuer pair; since $issuer is a required constructor argument, "validate without an issuer" is no longer representable, so that runtime exception path is removed along with its tests. NameId and Attribute move from Litesaml\Models\Messages to Litesaml\Models\Messages\Context and now implement Context. sendLogoutRequest()'s NameId is no longer statically required; a missing one now throws SamlException at call time via ContextList::required(). Co-Authored-By: Claude Sonnet 5 --- src/IdentityProviderWrapper.php | 52 ++++---- src/Models/Messages/AuthnResponse.php | 2 + .../Messages/{ => Context}/Attribute.php | 4 +- src/Models/Messages/Context/Context.php | 7 + src/Models/Messages/Context/ContextList.php | 54 ++++++++ src/Models/Messages/{ => Context}/NameId.php | 4 +- .../Messages/Context/NameIdPolicyFormat.php | 11 ++ src/Models/Messages/Context/RelayState.php | 11 ++ src/Models/Messages/Context/SessionIndex.php | 11 ++ src/Models/Messages/Context/Validate.php | 13 ++ src/Models/Messages/LogoutRequest.php | 2 + src/ServiceProviderWrapper.php | 54 ++++---- tests/IdentityProviderWrapperTest.php | 91 ++++++------- tests/ServiceProviderWrapperTest.php | 123 ++++++++---------- 14 files changed, 264 insertions(+), 175 deletions(-) rename src/Models/Messages/{ => Context}/Attribute.php (70%) create mode 100644 src/Models/Messages/Context/Context.php create mode 100644 src/Models/Messages/Context/ContextList.php rename src/Models/Messages/{ => Context}/NameId.php (60%) create mode 100644 src/Models/Messages/Context/NameIdPolicyFormat.php create mode 100644 src/Models/Messages/Context/RelayState.php create mode 100644 src/Models/Messages/Context/SessionIndex.php create mode 100644 src/Models/Messages/Context/Validate.php diff --git a/src/IdentityProviderWrapper.php b/src/IdentityProviderWrapper.php index 752be72..b048152 100644 --- a/src/IdentityProviderWrapper.php +++ b/src/IdentityProviderWrapper.php @@ -31,11 +31,15 @@ use Litesaml\Models\Descriptors\Entity; use Litesaml\Models\Descriptors\Idp; use Litesaml\Models\Descriptors\Sp; -use Litesaml\Models\Messages\Attribute; use Litesaml\Models\Messages\AuthnRequest; +use Litesaml\Models\Messages\Context\Attribute; +use Litesaml\Models\Messages\Context\ContextList; +use Litesaml\Models\Messages\Context\NameId; +use Litesaml\Models\Messages\Context\RelayState; +use Litesaml\Models\Messages\Context\SessionIndex; +use Litesaml\Models\Messages\Context\Validate; use Litesaml\Models\Messages\LogoutRequest; use Litesaml\Models\Messages\LogoutResponse; -use Litesaml\Models\Messages\NameId; use Litesaml\Support\MessageHandler; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -77,17 +81,17 @@ public function generateMetadata(): string $entityDescriptor = (new EntityDescriptor($this->idp->entityId)) ->addItem($idpSsoDescriptor); - $context = new SerializationContext(); - $entityDescriptor->serialize($context->getDocument(), $context); + $serializationContext = new SerializationContext(); + $entityDescriptor->serialize($serializationContext->getDocument(), $serializationContext); - return (string) $context->getDocument()->saveXML(); + return (string) $serializationContext->getDocument()->saveXML(); } - /** - * @param Attribute[] $attributes - */ - public function sendAuthnResponse(Sp $recipient, array $attributes, ?NameId $nameId = null): ResponseInterface + public function sendAuthnResponse(Sp $recipient, ContextList $context = new ContextList()): ResponseInterface { + $attributes = $context->all(Attribute::class); + $nameId = $context->first(NameId::class); + $response = new LightSamlAuthnResponse(); $response @@ -170,7 +174,7 @@ public function sendAuthnResponse(Sp $recipient, array $attributes, ?NameId $nam return $this->messageHandler->send($response, $this->idp, $recipient->acs); } - public function handleAuthnRequest(ServerRequestInterface $request, bool $validate = false, ?Entity $issuer = null): AuthnRequest + public function handleAuthnRequest(ServerRequestInterface $request, ContextList $context = new ContextList()): AuthnRequest { $message = $this->messageHandler->unpack($request); @@ -185,13 +189,17 @@ public function handleAuthnRequest(ServerRequestInterface $request, bool $valida nameIdPolicyFormat: $message->getNameIDPolicy()?->getFormat(), ); - $this->validateIfRequested($message, $validate, $issuer); + $this->validateIfRequested($message, $context); return $dto; } - public function sendLogoutRequest(Entity $recipient, NameId $nameId, ?string $relayState = null, ?string $sessionIndex = null): ResponseInterface + public function sendLogoutRequest(Entity $recipient, ContextList $context = new ContextList()): ResponseInterface { + $nameId = $context->required(NameId::class, 'A NameId is required to send a LogoutRequest'); + $relayState = $context->first(RelayState::class)?->value; + $sessionIndex = $context->first(SessionIndex::class)?->value; + $logoutRequest = (new LightSamlLogoutRequest()) ->setID(Helper::generateID()) ->setIssueInstant(new DateTime()) @@ -216,7 +224,7 @@ public function sendLogoutResponse(Entity $recipient): ResponseInterface return $this->messageHandler->send($logoutResponse, $this->idp, $recipient->slo); } - public function handleLogoutRequest(ServerRequestInterface $request, bool $validate = false, ?Entity $issuer = null): LogoutRequest + public function handleLogoutRequest(ServerRequestInterface $request, ContextList $context = new ContextList()): LogoutRequest { $message = $this->messageHandler->unpack($request); @@ -232,12 +240,12 @@ public function handleLogoutRequest(ServerRequestInterface $request, bool $valid relayState: $message->getRelayState(), ); - $this->validateIfRequested($message, $validate, $issuer); + $this->validateIfRequested($message, $context); return $dto; } - public function handleLogoutResponse(ServerRequestInterface $request, bool $validate = false, ?Entity $issuer = null): LogoutResponse + public function handleLogoutResponse(ServerRequestInterface $request, ContextList $context = new ContextList()): LogoutResponse { $message = $this->messageHandler->unpack($request); @@ -251,22 +259,20 @@ public function handleLogoutResponse(ServerRequestInterface $request, bool $vali relayState: $message->getRelayState(), ); - $this->validateIfRequested($message, $validate, $issuer); + $this->validateIfRequested($message, $context); return $dto; } - private function validateIfRequested(SamlMessage $message, bool $validate, ?Entity $issuer): void + private function validateIfRequested(SamlMessage $message, ContextList $context): void { - if (!$validate) { - return; - } + $validate = $context->first(Validate::class); - if ($issuer === null) { - throw new SamlException('An issuer must be provided to validate the signature'); + if ($validate === null) { + return; } - if (!$this->messageHandler->validateSignature($message, $issuer)) { + if (!$this->messageHandler->validateSignature($message, $validate->issuer)) { throw new SamlException('Invalid signature'); } } diff --git a/src/Models/Messages/AuthnResponse.php b/src/Models/Messages/AuthnResponse.php index 429e7e3..1d6f899 100644 --- a/src/Models/Messages/AuthnResponse.php +++ b/src/Models/Messages/AuthnResponse.php @@ -3,6 +3,8 @@ namespace Litesaml\Models\Messages; use Litesaml\Enums\Status; +use Litesaml\Models\Messages\Context\Attribute; +use Litesaml\Models\Messages\Context\NameId; readonly class AuthnResponse extends Message { diff --git a/src/Models/Messages/Attribute.php b/src/Models/Messages/Context/Attribute.php similarity index 70% rename from src/Models/Messages/Attribute.php rename to src/Models/Messages/Context/Attribute.php index 5304396..289601f 100644 --- a/src/Models/Messages/Attribute.php +++ b/src/Models/Messages/Context/Attribute.php @@ -1,8 +1,8 @@ $values diff --git a/src/Models/Messages/Context/Context.php b/src/Models/Messages/Context/Context.php new file mode 100644 index 0000000..831d898 --- /dev/null +++ b/src/Models/Messages/Context/Context.php @@ -0,0 +1,7 @@ +, Context[]> */ + private array $items = []; + + public function __construct(Context ...$items) + { + foreach ($items as $item) { + $this->items[$item::class][] = $item; + } + } + + /** + * @template T of Context + * + * @param class-string $class + * + * @return T[] + */ + public function all(string $class): array + { + return $this->items[$class] ?? []; + } + + /** + * @template T of Context + * + * @param class-string $class + * + * @return T|null + */ + public function first(string $class): ?Context + { + return $this->items[$class][0] ?? null; + } + + /** + * @template T of Context + * + * @param class-string $class + * + * @return T + */ + public function required(string $class, string $message): Context + { + return $this->first($class) ?? throw new SamlException($message); + } +} diff --git a/src/Models/Messages/NameId.php b/src/Models/Messages/Context/NameId.php similarity index 60% rename from src/Models/Messages/NameId.php rename to src/Models/Messages/Context/NameId.php index abe9843..b14771f 100644 --- a/src/Models/Messages/NameId.php +++ b/src/Models/Messages/Context/NameId.php @@ -1,8 +1,8 @@ sp->entityId)) ->addItem($spSsoDescriptor); - $context = new SerializationContext(); - $entityDescriptor->serialize($context->getDocument(), $context); + $serializationContext = new SerializationContext(); + $entityDescriptor->serialize($serializationContext->getDocument(), $serializationContext); - return (string) $context->getDocument()->saveXML(); + return (string) $serializationContext->getDocument()->saveXML(); } - public function sendAuthnRequest(Idp $recipient, ?string $relayState = null, ?string $nameIdPolicyFormat = null): ResponseInterface + public function sendAuthnRequest(Idp $recipient, ContextList $context = new ContextList()): ResponseInterface { + $relayState = $context->first(RelayState::class)?->value; + $nameIdPolicyFormat = $context->first(NameIdPolicyFormat::class)?->value; + $authnRequest = (new LightSamlAuthnRequest()) ->setAssertionConsumerServiceURL($this->sp->acs->location) ->setProtocolBinding($this->sp->acs->getBinding()) @@ -105,7 +113,7 @@ public function sendAuthnRequest(Idp $recipient, ?string $relayState = null, ?st return $this->messageHandler->send($authnRequest, $this->sp, $recipient->sso); } - public function handleAuthnResponse(ServerRequestInterface $request, bool $validate = false, ?Entity $issuer = null): AuthnResponse + public function handleAuthnResponse(ServerRequestInterface $request, ContextList $context = new ContextList()): AuthnResponse { $message = $this->messageHandler->unpack($request); @@ -184,12 +192,12 @@ public function handleAuthnResponse(ServerRequestInterface $request, bool $valid relayState: $message->getRelayState(), ); - $this->validateIfRequested($message, $validate, $issuer); + $this->validateIfRequested($message, $context); return $dto; } - public function handleAuthnRequest(ServerRequestInterface $request, bool $validate = false, ?Entity $issuer = null): AuthnRequest + public function handleAuthnRequest(ServerRequestInterface $request, ContextList $context = new ContextList()): AuthnRequest { $message = $this->messageHandler->unpack($request); @@ -204,13 +212,17 @@ public function handleAuthnRequest(ServerRequestInterface $request, bool $valida nameIdPolicyFormat: $message->getNameIDPolicy()?->getFormat(), ); - $this->validateIfRequested($message, $validate, $issuer); + $this->validateIfRequested($message, $context); return $dto; } - public function sendLogoutRequest(Entity $recipient, NameId $nameId, ?string $relayState = null, ?string $sessionIndex = null): ResponseInterface + public function sendLogoutRequest(Entity $recipient, ContextList $context = new ContextList()): ResponseInterface { + $nameId = $context->required(NameId::class, 'A NameId is required to send a LogoutRequest'); + $relayState = $context->first(RelayState::class)?->value; + $sessionIndex = $context->first(SessionIndex::class)?->value; + $logoutRequest = (new LightSamlLogoutRequest()) ->setID(Helper::generateID()) ->setIssueInstant(new DateTime()) @@ -235,7 +247,7 @@ public function sendLogoutResponse(Entity $recipient): ResponseInterface return $this->messageHandler->send($logoutResponse, $this->sp, $recipient->slo); } - public function handleLogoutRequest(ServerRequestInterface $request, bool $validate = false, ?Entity $issuer = null): LogoutRequest + public function handleLogoutRequest(ServerRequestInterface $request, ContextList $context = new ContextList()): LogoutRequest { $message = $this->messageHandler->unpack($request); @@ -251,12 +263,12 @@ public function handleLogoutRequest(ServerRequestInterface $request, bool $valid relayState: $message->getRelayState(), ); - $this->validateIfRequested($message, $validate, $issuer); + $this->validateIfRequested($message, $context); return $dto; } - public function handleLogoutResponse(ServerRequestInterface $request, bool $validate = false, ?Entity $issuer = null): LogoutResponse + public function handleLogoutResponse(ServerRequestInterface $request, ContextList $context = new ContextList()): LogoutResponse { $message = $this->messageHandler->unpack($request); @@ -270,22 +282,20 @@ public function handleLogoutResponse(ServerRequestInterface $request, bool $vali relayState: $message->getRelayState(), ); - $this->validateIfRequested($message, $validate, $issuer); + $this->validateIfRequested($message, $context); return $dto; } - private function validateIfRequested(SamlMessage $message, bool $validate, ?Entity $issuer): void + private function validateIfRequested(SamlMessage $message, ContextList $context): void { - if (!$validate) { - return; - } + $validate = $context->first(Validate::class); - if ($issuer === null) { - throw new SamlException('An issuer must be provided to validate the signature'); + if ($validate === null) { + return; } - if (!$this->messageHandler->validateSignature($message, $issuer)) { + if (!$this->messageHandler->validateSignature($message, $validate->issuer)) { throw new SamlException('Invalid signature'); } } diff --git a/tests/IdentityProviderWrapperTest.php b/tests/IdentityProviderWrapperTest.php index cfffc83..2f62748 100644 --- a/tests/IdentityProviderWrapperTest.php +++ b/tests/IdentityProviderWrapperTest.php @@ -3,12 +3,14 @@ namespace Tests; use Litesaml\Exceptions\SamlException; -use Litesaml\Models\Messages\Attribute; use Litesaml\Models\Messages\AuthnRequest; use Litesaml\Models\Messages\AuthnResponse; +use Litesaml\Models\Messages\Context\Attribute; +use Litesaml\Models\Messages\Context\ContextList; +use Litesaml\Models\Messages\Context\NameId; +use Litesaml\Models\Messages\Context\Validate; use Litesaml\Models\Messages\LogoutRequest; use Litesaml\Models\Messages\LogoutResponse; -use Litesaml\Models\Messages\NameId; use Litesaml\Support\MetadataParser; use PHPUnit\Framework\Attributes\Test; @@ -71,12 +73,12 @@ public function handle_authn_request_throws_on_wrong_message_type(): void #[Test] public function can_send_authn_response(): void { - $attributes = [ + $context = new ContextList( new Attribute(name: 'email', values: ['user@example.com']), new Attribute(name: 'roles', values: ['admin', 'editor']), - ]; + ); - $response = $this->makeIdpWrapper()->sendAuthnResponse($this->makeSp(), $attributes); + $response = $this->makeIdpWrapper()->sendAuthnResponse($this->makeSp(), $context); $this->assertEquals(302, $response->getStatusCode()); $this->assertStringContainsString('https://sp.localhost/acs', $response->getHeaderLine('Location')); @@ -93,14 +95,13 @@ public function can_send_authn_response(): void #[Test] public function can_send_authn_response_with_name_id(): void { - $attributes = [new Attribute(name: 'email', values: ['user@example.com'])]; - - $response = $this->makeIdpWrapper()->sendAuthnResponse( - $this->makeSp(), - $attributes, + $context = new ContextList( + new Attribute(name: 'email', values: ['user@example.com']), new NameId('user@example.com', 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'), ); + $response = $this->makeIdpWrapper()->sendAuthnResponse($this->makeSp(), $context); + parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); $authnResponse = $this->makeSpWrapper()->handleAuthnResponse( $this->makeGetRequest('/acs', $params) @@ -113,12 +114,24 @@ public function can_send_authn_response_with_name_id(): void #[Test] public function can_send_logout_request(): void { - $response = $this->makeIdpWrapper()->sendLogoutRequest($this->makeSp(), new NameId('user@example.com')); + $response = $this->makeIdpWrapper()->sendLogoutRequest( + $this->makeSp(), + new ContextList(new NameId('user@example.com')), + ); $this->assertEquals(302, $response->getStatusCode()); $this->assertStringContainsString('https://sp.localhost/slo', $response->getHeaderLine('Location')); } + #[Test] + public function send_logout_request_throws_when_name_id_missing(): void + { + $this->expectException(SamlException::class); + $this->expectExceptionMessage('A NameId is required to send a LogoutRequest'); + + $this->makeIdpWrapper()->sendLogoutRequest($this->makeSp()); + } + #[Test] public function can_send_logout_response(): void { @@ -148,7 +161,7 @@ public function can_send_logout_request_with_name_id_format(): void { $response = $this->makeIdpWrapper()->sendLogoutRequest( $this->makeSp(), - new NameId('user@example.com', 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'), + new ContextList(new NameId('user@example.com', 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent')), ); parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); @@ -173,13 +186,13 @@ public function can_handle_logout_response(): void #[Test] public function send_authn_response_with_encrypted_attribute(): void { - $attributes = [ + $context = new ContextList( new Attribute(name: 'email', values: ['user@example.com']), new Attribute(name: 'roles', values: ['admin'], encrypted: true), - ]; + ); $sp = $this->makeSpWithEncryption(); - $response = $this->makeIdpWrapper()->sendAuthnResponse($sp, $attributes); + $response = $this->makeIdpWrapper()->sendAuthnResponse($sp, $context); parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); $authnResponse = $this->makeSpWrapper($sp)->handleAuthnResponse( @@ -199,19 +212,9 @@ public function send_authn_response_throws_when_encrypted_attribute_but_no_sp_en $this->expectException(SamlException::class); $this->expectExceptionMessage('No encryption certificate configured on recipient SP'); - $attributes = [new Attribute(name: 'roles', values: ['admin'], encrypted: true)]; + $context = new ContextList(new Attribute(name: 'roles', values: ['admin'], encrypted: true)); - $this->makeIdpWrapper()->sendAuthnResponse($this->makeSp(), $attributes); - } - - #[Test] - public function handle_authn_request_throws_when_validate_requires_issuer(): void - { - $this->expectException(SamlException::class); - $this->expectExceptionMessage('An issuer must be provided to validate the signature'); - - $request = $this->makeGetRequest('/sso', ['SAMLRequest' => $this->fixture('authn_request')]); - $this->makeIdpWrapper()->handleAuthnRequest($request, validate: true); + $this->makeIdpWrapper()->sendAuthnResponse($this->makeSp(), $context); } #[Test] @@ -221,7 +224,7 @@ public function handle_authn_request_throws_on_invalid_signature(): void $this->expectExceptionMessage('Invalid signature'); $request = $this->makeGetRequest('/sso', ['SAMLRequest' => $this->fixture('authn_request')]); - $this->makeIdpWrapper()->handleAuthnRequest($request, validate: true, issuer: $this->makeSp()); + $this->makeIdpWrapper()->handleAuthnRequest($request, new ContextList(new Validate($this->makeSp()))); } #[Test] @@ -233,21 +236,11 @@ public function handle_authn_request_validates_signature_when_requested(): void parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); $request = $this->makeGetRequest('/sso', $params); - $message = $this->makeIdpWrapper()->handleAuthnRequest($request, validate: true, issuer: $this->makeSpWithSigning()); + $message = $this->makeIdpWrapper()->handleAuthnRequest($request, new ContextList(new Validate($this->makeSpWithSigning()))); $this->assertInstanceOf(AuthnRequest::class, $message); } - #[Test] - public function handle_logout_request_throws_when_validate_requires_issuer(): void - { - $this->expectException(SamlException::class); - $this->expectExceptionMessage('An issuer must be provided to validate the signature'); - - $request = $this->makeGetRequest('/slo', ['SAMLRequest' => $this->fixture('logout_request')]); - $this->makeIdpWrapper()->handleLogoutRequest($request, validate: true); - } - #[Test] public function handle_logout_request_throws_on_invalid_signature(): void { @@ -255,7 +248,7 @@ public function handle_logout_request_throws_on_invalid_signature(): void $this->expectExceptionMessage('Invalid signature'); $request = $this->makeGetRequest('/slo', ['SAMLRequest' => $this->fixture('logout_request')]); - $this->makeIdpWrapper()->handleLogoutRequest($request, validate: true, issuer: $this->makeSp()); + $this->makeIdpWrapper()->handleLogoutRequest($request, new ContextList(new Validate($this->makeSp()))); } #[Test] @@ -263,25 +256,15 @@ public function handle_logout_request_validates_signature_when_requested(): void { $spWithSigning = $this->makeSpWrapper($this->makeSpWithSigning()); - $response = $spWithSigning->sendLogoutRequest($this->makeIdp(), new NameId('user@example.com')); + $response = $spWithSigning->sendLogoutRequest($this->makeIdp(), new ContextList(new NameId('user@example.com'))); parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); $request = $this->makeGetRequest('/slo', $params); - $message = $this->makeIdpWrapper()->handleLogoutRequest($request, validate: true, issuer: $this->makeSpWithSigning()); + $message = $this->makeIdpWrapper()->handleLogoutRequest($request, new ContextList(new Validate($this->makeSpWithSigning()))); $this->assertInstanceOf(LogoutRequest::class, $message); } - #[Test] - public function handle_logout_response_throws_when_validate_requires_issuer(): void - { - $this->expectException(SamlException::class); - $this->expectExceptionMessage('An issuer must be provided to validate the signature'); - - $request = $this->makeGetRequest('/slo', ['SAMLResponse' => $this->fixture('logout_response')]); - $this->makeIdpWrapper()->handleLogoutResponse($request, validate: true); - } - #[Test] public function handle_logout_response_throws_on_invalid_signature(): void { @@ -289,7 +272,7 @@ public function handle_logout_response_throws_on_invalid_signature(): void $this->expectExceptionMessage('Invalid signature'); $request = $this->makeGetRequest('/slo', ['SAMLResponse' => $this->fixture('logout_response')]); - $this->makeIdpWrapper()->handleLogoutResponse($request, validate: true, issuer: $this->makeSp()); + $this->makeIdpWrapper()->handleLogoutResponse($request, new ContextList(new Validate($this->makeSp()))); } #[Test] @@ -319,7 +302,7 @@ public function handle_logout_response_validates_signature_when_requested(): voi parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); $request = $this->makeGetRequest('/slo', $params); - $message = $this->makeIdpWrapper()->handleLogoutResponse($request, validate: true, issuer: $this->makeSpWithSigning()); + $message = $this->makeIdpWrapper()->handleLogoutResponse($request, new ContextList(new Validate($this->makeSpWithSigning()))); $this->assertInstanceOf(LogoutResponse::class, $message); } diff --git a/tests/ServiceProviderWrapperTest.php b/tests/ServiceProviderWrapperTest.php index 7c00a1c..75ba201 100644 --- a/tests/ServiceProviderWrapperTest.php +++ b/tests/ServiceProviderWrapperTest.php @@ -5,12 +5,16 @@ use LightSaml\Binding\SamlPostResponse; use Litesaml\Enums\Status; use Litesaml\Exceptions\SamlException; -use Litesaml\Models\Messages\Attribute; use Litesaml\Models\Messages\AuthnRequest; use Litesaml\Models\Messages\AuthnResponse; +use Litesaml\Models\Messages\Context\Attribute; +use Litesaml\Models\Messages\Context\ContextList; +use Litesaml\Models\Messages\Context\NameId; +use Litesaml\Models\Messages\Context\NameIdPolicyFormat; +use Litesaml\Models\Messages\Context\RelayState; +use Litesaml\Models\Messages\Context\Validate; use Litesaml\Models\Messages\LogoutRequest; use Litesaml\Models\Messages\LogoutResponse; -use Litesaml\Models\Messages\NameId; use Litesaml\Support\MetadataParser; use PHPUnit\Framework\Attributes\Test; @@ -28,12 +32,12 @@ public function generate_metadata_includes_encryption_key_descriptor(): void public function handle_authn_response_decrypts_encrypted_assertions(): void { $sp = $this->makeSpWithEncryption(); - $attributes = [ + $context = new ContextList( new Attribute(name: 'email', values: ['user@example.com']), new Attribute(name: 'roles', values: ['admin'], encrypted: true), - ]; + ); - $response = $this->makeIdpWrapper()->sendAuthnResponse($sp, $attributes); + $response = $this->makeIdpWrapper()->sendAuthnResponse($sp, $context); parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); $message = $this->makeSpWrapper($sp)->handleAuthnResponse($this->makeGetRequest('/acs', $params)); @@ -51,8 +55,8 @@ public function handle_authn_response_throws_when_encrypted_assertion_without_sp $this->expectExceptionMessage('No encryption certificate configured to decrypt assertion'); $sp = $this->makeSpWithEncryption(); - $attributes = [new Attribute(name: 'roles', values: ['admin'], encrypted: true)]; - $response = $this->makeIdpWrapper()->sendAuthnResponse($sp, $attributes); + $context = new ContextList(new Attribute(name: 'roles', values: ['admin'], encrypted: true)); + $response = $this->makeIdpWrapper()->sendAuthnResponse($sp, $context); parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); $this->makeSpWrapper()->handleAuthnResponse($this->makeGetRequest('/acs', $params)); @@ -104,7 +108,7 @@ public function send_authn_request_includes_name_id_policy_format(): void { $response = $this->makeSpWrapper()->sendAuthnRequest( $this->makeIdp(), - nameIdPolicyFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + new ContextList(new NameIdPolicyFormat('urn:oasis:names:tc:SAML:2.0:nameid-format:persistent')), ); parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); @@ -116,7 +120,7 @@ public function send_authn_request_includes_name_id_policy_format(): void #[Test] public function send_authn_request_includes_relay_state(): void { - $response = $this->makeSpWrapper()->sendAuthnRequest($this->makeIdp(), 'my-relay-state'); + $response = $this->makeSpWrapper()->sendAuthnRequest($this->makeIdp(), new ContextList(new RelayState('my-relay-state'))); $this->assertStringContainsString('RelayState=my-relay-state', $response->getHeaderLine('Location')); } @@ -197,16 +201,31 @@ public function can_handle_authn_request(): void #[Test] public function can_send_logout_request(): void { - $response = $this->makeSpWrapper()->sendLogoutRequest($this->makeIdp(), new NameId('user@example.com')); + $response = $this->makeSpWrapper()->sendLogoutRequest( + $this->makeIdp(), + new ContextList(new NameId('user@example.com')), + ); $this->assertEquals(302, $response->getStatusCode()); $this->assertStringContainsString('https://idp.localhost/slo', $response->getHeaderLine('Location')); } + #[Test] + public function send_logout_request_throws_when_name_id_missing(): void + { + $this->expectException(SamlException::class); + $this->expectExceptionMessage('A NameId is required to send a LogoutRequest'); + + $this->makeSpWrapper()->sendLogoutRequest($this->makeIdp()); + } + #[Test] public function send_logout_request_includes_relay_state(): void { - $response = $this->makeSpWrapper()->sendLogoutRequest($this->makeIdp(), new NameId('user@example.com'), 'my-relay-state'); + $response = $this->makeSpWrapper()->sendLogoutRequest( + $this->makeIdp(), + new ContextList(new NameId('user@example.com'), new RelayState('my-relay-state')), + ); $this->assertStringContainsString('RelayState=my-relay-state', $response->getHeaderLine('Location')); } @@ -240,7 +259,7 @@ public function can_send_logout_request_with_name_id_format(): void { $response = $this->makeSpWrapper()->sendLogoutRequest( $this->makeIdp(), - new NameId('user@example.com', 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'), + new ContextList(new NameId('user@example.com', 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent')), ); parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); @@ -262,16 +281,6 @@ public function can_handle_logout_response(): void $this->assertEquals('https://idp.localhost', $message->issuer); } - #[Test] - public function handle_authn_response_throws_when_validate_requires_issuer(): void - { - $this->expectException(SamlException::class); - $this->expectExceptionMessage('An issuer must be provided to validate the signature'); - - $request = $this->makePostRequest('/acs', ['SAMLResponse' => $this->fixture('authn_response', deflate: false)]); - $this->makeSpWrapper()->handleAuthnResponse($request, validate: true); - } - #[Test] public function handle_authn_response_throws_on_invalid_signature(): void { @@ -279,20 +288,20 @@ public function handle_authn_response_throws_on_invalid_signature(): void $this->expectExceptionMessage('Invalid signature'); $request = $this->makePostRequest('/acs', ['SAMLResponse' => $this->fixture('authn_response', deflate: false)]); - $this->makeSpWrapper()->handleAuthnResponse($request, validate: true, issuer: $this->makeIdp()); + $this->makeSpWrapper()->handleAuthnResponse($request, new ContextList(new Validate($this->makeIdp()))); } #[Test] public function handle_authn_response_validates_signature_when_requested(): void { $idpWithSigning = $this->makeIdpWrapper($this->makeIdpWithSigning()); - $attributes = [new Attribute(name: 'email', values: ['user@example.com'])]; + $context = new ContextList(new Attribute(name: 'email', values: ['user@example.com'])); - $response = $idpWithSigning->sendAuthnResponse($this->makeSp(), $attributes); + $response = $idpWithSigning->sendAuthnResponse($this->makeSp(), $context); parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); $request = $this->makeGetRequest('/acs', $params); - $message = $this->makeSpWrapper()->handleAuthnResponse($request, validate: true, issuer: $this->makeIdpWithSigning()); + $message = $this->makeSpWrapper()->handleAuthnResponse($request, new ContextList(new Validate($this->makeIdpWithSigning()))); $this->assertInstanceOf(AuthnResponse::class, $message); } @@ -302,14 +311,14 @@ public function handle_authn_response_validates_signature_for_post_binding(): vo { $idpWithSigning = $this->makeIdpWrapper($this->makeIdpWithSigning()); $sp = $this->makeSpWithPostAcs(); - $attributes = [new Attribute(name: 'email', values: ['user@example.com'])]; + $context = new ContextList(new Attribute(name: 'email', values: ['user@example.com'])); - $response = $idpWithSigning->sendAuthnResponse($sp, $attributes); + $response = $idpWithSigning->sendAuthnResponse($sp, $context); $this->assertInstanceOf(SamlPostResponse::class, $response); $request = $this->makePostRequest('/acs', $response->getData()); - $message = $this->makeSpWrapper($sp)->handleAuthnResponse($request, validate: true, issuer: $this->makeIdpWithSigning()); + $message = $this->makeSpWrapper($sp)->handleAuthnResponse($request, new ContextList(new Validate($this->makeIdpWithSigning()))); $this->assertInstanceOf(AuthnResponse::class, $message); } @@ -322,9 +331,9 @@ public function handle_authn_response_throws_on_tampered_post_binding_signature( $idpWithSigning = $this->makeIdpWrapper($this->makeIdpWithSigning()); $sp = $this->makeSpWithPostAcs(); - $attributes = [new Attribute(name: 'email', values: ['user@example.com'])]; + $context = new ContextList(new Attribute(name: 'email', values: ['user@example.com'])); - $response = $idpWithSigning->sendAuthnResponse($sp, $attributes); + $response = $idpWithSigning->sendAuthnResponse($sp, $context); $this->assertInstanceOf(SamlPostResponse::class, $response); $data = $response->getData(); @@ -333,7 +342,7 @@ public function handle_authn_response_throws_on_tampered_post_binding_signature( $request = $this->makePostRequest('/acs', $data); - $this->makeSpWrapper($sp)->handleAuthnResponse($request, validate: true, issuer: $this->makeIdpWithSigning()); + $this->makeSpWrapper($sp)->handleAuthnResponse($request, new ContextList(new Validate($this->makeIdpWithSigning()))); } #[Test] @@ -347,9 +356,9 @@ public function handle_authn_response_rejects_xml_signature_wrapping_attack(): v $idpWithSigning = $this->makeIdpWrapper($this->makeIdpWithSigning()); $sp = $this->makeSpWithPostAcs(); - $attributes = [new Attribute(name: 'email', values: ['user@example.com'])]; + $context = new ContextList(new Attribute(name: 'email', values: ['user@example.com'])); - $response = $idpWithSigning->sendAuthnResponse($sp, $attributes); + $response = $idpWithSigning->sendAuthnResponse($sp, $context); $this->assertInstanceOf(SamlPostResponse::class, $response); $data = $response->getData(); @@ -372,17 +381,7 @@ public function handle_authn_response_rejects_xml_signature_wrapping_attack(): v ); $request = $this->makePostRequest('/acs', ['SAMLResponse' => base64_encode($wrapped)]); - $this->makeSpWrapper($sp)->handleAuthnResponse($request, validate: true, issuer: $this->makeIdpWithSigning()); - } - - #[Test] - public function handle_authn_request_throws_when_validate_requires_issuer(): void - { - $this->expectException(SamlException::class); - $this->expectExceptionMessage('An issuer must be provided to validate the signature'); - - $request = $this->makeGetRequest('/sso', ['SAMLRequest' => $this->fixture('authn_request')]); - $this->makeSpWrapper()->handleAuthnRequest($request, validate: true); + $this->makeSpWrapper($sp)->handleAuthnResponse($request, new ContextList(new Validate($this->makeIdpWithSigning()))); } #[Test] @@ -392,7 +391,7 @@ public function handle_authn_request_throws_on_invalid_signature(): void $this->expectExceptionMessage('Invalid signature'); $request = $this->makeGetRequest('/sso', ['SAMLRequest' => $this->fixture('authn_request')]); - $this->makeSpWrapper()->handleAuthnRequest($request, validate: true, issuer: $this->makeSp()); + $this->makeSpWrapper()->handleAuthnRequest($request, new ContextList(new Validate($this->makeSp()))); } #[Test] @@ -404,7 +403,7 @@ public function handle_authn_request_validates_signature_when_requested(): void parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); $request = $this->makeGetRequest('/sso', $params); - $message = $this->makeSpWrapper()->handleAuthnRequest($request, validate: true, issuer: $this->makeSpWithSigning()); + $message = $this->makeSpWrapper()->handleAuthnRequest($request, new ContextList(new Validate($this->makeSpWithSigning()))); $this->assertInstanceOf(AuthnRequest::class, $message); } @@ -427,16 +426,6 @@ public function handle_logout_response_throws_on_wrong_message_type(): void $this->makeSpWrapper()->handleLogoutResponse($request); } - #[Test] - public function handle_logout_request_throws_when_validate_requires_issuer(): void - { - $this->expectException(SamlException::class); - $this->expectExceptionMessage('An issuer must be provided to validate the signature'); - - $request = $this->makeGetRequest('/slo', ['SAMLRequest' => $this->fixture('logout_request')]); - $this->makeSpWrapper()->handleLogoutRequest($request, validate: true); - } - #[Test] public function handle_logout_request_throws_on_invalid_signature(): void { @@ -444,7 +433,7 @@ public function handle_logout_request_throws_on_invalid_signature(): void $this->expectExceptionMessage('Invalid signature'); $request = $this->makeGetRequest('/slo', ['SAMLRequest' => $this->fixture('logout_request')]); - $this->makeSpWrapper()->handleLogoutRequest($request, validate: true, issuer: $this->makeIdp()); + $this->makeSpWrapper()->handleLogoutRequest($request, new ContextList(new Validate($this->makeIdp()))); } #[Test] @@ -452,25 +441,15 @@ public function handle_logout_request_validates_signature_when_requested(): void { $idpWithSigning = $this->makeIdpWrapper($this->makeIdpWithSigning()); - $response = $idpWithSigning->sendLogoutRequest($this->makeSp(), new NameId('user@example.com')); + $response = $idpWithSigning->sendLogoutRequest($this->makeSp(), new ContextList(new NameId('user@example.com'))); parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); $request = $this->makeGetRequest('/slo', $params); - $message = $this->makeSpWrapper()->handleLogoutRequest($request, validate: true, issuer: $this->makeIdpWithSigning()); + $message = $this->makeSpWrapper()->handleLogoutRequest($request, new ContextList(new Validate($this->makeIdpWithSigning()))); $this->assertInstanceOf(LogoutRequest::class, $message); } - #[Test] - public function handle_logout_response_throws_when_validate_requires_issuer(): void - { - $this->expectException(SamlException::class); - $this->expectExceptionMessage('An issuer must be provided to validate the signature'); - - $request = $this->makeGetRequest('/slo', ['SAMLResponse' => $this->fixture('logout_response')]); - $this->makeSpWrapper()->handleLogoutResponse($request, validate: true); - } - #[Test] public function handle_logout_response_throws_on_invalid_signature(): void { @@ -478,7 +457,7 @@ public function handle_logout_response_throws_on_invalid_signature(): void $this->expectExceptionMessage('Invalid signature'); $request = $this->makeGetRequest('/slo', ['SAMLResponse' => $this->fixture('logout_response')]); - $this->makeSpWrapper()->handleLogoutResponse($request, validate: true, issuer: $this->makeIdp()); + $this->makeSpWrapper()->handleLogoutResponse($request, new ContextList(new Validate($this->makeIdp()))); } #[Test] @@ -490,7 +469,7 @@ public function handle_logout_response_validates_signature_when_requested(): voi parse_str((string) parse_url($response->getHeaderLine('Location'), PHP_URL_QUERY), $params); $request = $this->makeGetRequest('/slo', $params); - $message = $this->makeSpWrapper()->handleLogoutResponse($request, validate: true, issuer: $this->makeIdpWithSigning()); + $message = $this->makeSpWrapper()->handleLogoutResponse($request, new ContextList(new Validate($this->makeIdpWithSigning()))); $this->assertInstanceOf(LogoutResponse::class, $message); }