From 3485f44dab75176f8d6246b2a6a32666fd34c73c Mon Sep 17 00:00:00 2001 From: Brett Hazen Date: Thu, 26 Feb 2026 09:52:44 -0600 Subject: [PATCH] Initial SAML Drop (#2375) * Initial import of SAML code * Added missing SAML-relevant tests * Fixed compilation errors after rebasing XUnit V3 changes * Fixed subtle issues introduced in initial code import * Reworked test to use Kestrel based test hosts to allow cross-server communication * Eliminated odd value types to remain more consistent with code base * Do not enable SAML by default, but provide mechnism to opt-in * Base SAML test client * Add directions for configuring cert for SAML sample * Include encrypting assertions in SAML sample * Apply CT alias to SAML code * Dotnet format * Updated necessary SAML code after rebasing cooperative cancellation support * Updated SAML code to participate in cooperative cancellation * Fix name of SAML sign in state cookie --- Directory.Packages.props | 1 + .../aspire/AppHosts/All/All.csproj | 1 + .../aspire/AppHosts/All/Program.cs | 1 + .../aspire/AppHosts/All/appsettings.json | 1 + .../clients/src/MvcSaml/.gitignore | 1 + .../src/MvcSaml/Controllers/HomeController.cs | 23 + .../clients/src/MvcSaml/HostingExtensions.cs | 106 + .../clients/src/MvcSaml/MvcSaml.csproj | 29 + .../clients/src/MvcSaml/Program.cs | 36 + .../MvcSaml/Properties/launchSettings.json | 12 + identity-server/clients/src/MvcSaml/README.md | 41 + .../src/MvcSaml/Views/Home/Index.cshtml | 9 + .../src/MvcSaml/Views/Home/Secure.cshtml | 28 + .../src/MvcSaml/Views/Shared/_Layout.cshtml | 36 + .../src/MvcSaml/Views/_ViewImports.cshtml | 2 + .../src/MvcSaml/Views/_ViewStart.cshtml | 3 + .../hosts/Main10/IdentityServerExtensions.cs | 4 +- .../Configuration/SamlServiceProviders.cs | 68 + .../Customization/HostProfileService.cs | 2 +- identity-server/identity-server.slnf | 12 +- .../BuilderExtensions/Core.cs | 8 + .../BuilderExtensions/InMemory.cs | 13 + .../BuilderExtensions/Saml.cs | 134 + .../Options/EndpointOptions.cs | 48 + .../Options/IdentityServerOptions.cs | 5 + .../Options/SamlOptions.cs | 155 + .../Results/EndSessionCallbackResult.cs | 68 +- .../AuthenticationPropertiesExtensions.cs | 99 +- .../Extensions/EndpointOptionsExtensions.cs | 6 + .../Extensions/HttpContextExtensions.cs | 37 +- .../Extensions/HttpResponseExtensions.cs | 14 + .../IdentityServer/IdentityServerConstants.cs | 19 + .../Saml/DefaultSamlInteractionService.cs | 77 + .../Saml/EmptySamlServiceProviderStore.cs | 12 + .../Infrastructure/HttpResponseBindings.cs | 43 + .../Saml/Infrastructure/ISamlRequest.cs | 17 + .../Infrastructure/ISamlResultSerializer.cs | 11 + .../Infrastructure/ISamlSigningService.cs | 33 + .../Saml/Infrastructure/LimitedReadStream.cs | 56 + .../Saml/Infrastructure/RedirectResult.cs | 33 + .../Internal/Saml/Infrastructure/Result.cs | 38 + .../SamlAssertionEncryptor.Logging.cs | 66 + .../Infrastructure/SamlAssertionEncryptor.cs | 116 + .../Infrastructure/SamlBindingExtensions.cs | 34 + .../Saml/Infrastructure/SamlErrorResponse.cs | 100 + .../SamlErrorResponseXmlSerializer.cs | 59 + .../SamlProtocolMessageParser.cs | 76 + .../SamlProtocolMessageSigner.cs | 58 + .../Saml/Infrastructure/SamlRequestBase.cs | 30 + .../Saml/Infrastructure/SamlRequestError.cs | 26 + .../Infrastructure/SamlRequestExtractor.cs | 167 + .../SamlRequestProcessorBase.cs | 144 + .../SamlRequestSignatureValidator.cs | 191 ++ .../Infrastructure/SamlRequestValidator.cs | 88 + .../Saml/Infrastructure/SamlResponseSigner.cs | 57 + .../Saml/Infrastructure/SamlSigningService.cs | 72 + .../Saml/Infrastructure/SamlUrlBuilder.cs | 86 + .../Saml/Infrastructure/SecureXmlParser.cs | 194 ++ .../Saml/Infrastructure/XmlSignatureHelper.cs | 181 ++ .../IdentityServer/Internal/Saml/KeyUse.cs | 20 + .../src/IdentityServer/Internal/Saml/Log.cs | 192 ++ .../Metadata/EntityDescriptorSerializer.cs | 116 + .../Saml/Metadata/Models/EntityDescriptor.cs | 29 + .../Saml/Metadata/Models/IdpSsoDescriptor.cs | 50 + .../Saml/Metadata/Models/KeyDescriptor.cs | 23 + .../Metadata/Models/SingleLogoutService.cs | 26 + .../Metadata/Models/SingleSignOnService.cs | 26 + .../Saml/Metadata/SamlMetaDataEndpoint.cs | 124 + .../Saml/NopSamlLogoutNotificationService.cs | 15 + .../Internal/Saml/SamlClaimService.cs | 129 + .../Internal/Saml/SamlConstants.cs | 117 + .../Internal/Saml/SamlResponseBuilder.cs | 192 ++ .../Internal/Saml/SingleLogout/Log.cs | 268 ++ .../Saml/SingleLogout/LogoutRequestParser.cs | 130 + .../SingleLogout/LogoutResponseBuilder.cs | 68 + .../Saml/SingleLogout/Models/LogoutRequest.cs | 101 + .../SingleLogout/Models/LogoutResponse.cs | 132 + .../SingleLogout/Models/SamlLogoutRequest.cs | 52 + .../SamlFrontChannelLogoutRequestBuilder.cs | 142 + .../SamlHttpPostFrontChannelLogout.cs | 19 + .../SamlHttpRedirectFrontChannelLogout.cs | 19 + .../SamlLogoutCallbackProcessor.cs | 79 + .../SamlLogoutNotificationService.cs | 78 + .../SamlLogoutRequestProcessor.cs | 175 ++ .../Saml/SingleLogout/SamlLogoutResults.cs | 20 + .../SamlSingleLogoutCallbackEndpoint.cs | 50 + .../SingleLogout/SamlSingleLogoutEndpoint.cs | 77 + .../Saml/SingleSignin/AuthNRequestParser.cs | 140 + ...tSamlSigninInteractionResponseGenerator.cs | 86 + .../DistributedCacheSamlSigninStateStore.cs | 54 + .../SingleSignin/ISamlSigninStateStore.cs | 14 + .../Internal/Saml/SingleSignin/Log.cs | 48 + .../Saml/SingleSignin/Models/Assertion.cs | 63 + .../SingleSignin/Models/AttributeStatement.cs | 17 + .../Saml/SingleSignin/Models/AuthnContext.cs | 16 + .../SingleSignin/Models/AuthnStatement.cs | 31 + .../Models/AuthzDecisionStatement.cs | 26 + .../Saml/SingleSignin/Models/Conditions.cs | 27 + .../Saml/SingleSignin/Models/DecisionType.cs | 25 + .../Saml/SingleSignin/Models/Evidence.cs | 25 + .../SingleSignin/Models/NameIdentifier.cs | 32 + .../Models/SamlAuthenticationState.cs | 45 + .../Saml/SingleSignin/Models/SamlIds.cs | 16 + .../Saml/SingleSignin/Models/SamlResponse.cs | 337 ++ .../SingleSignin/Models/SamlSigninRequest.cs | 50 + .../Saml/SingleSignin/Models/StateId.cs | 11 + .../Saml/SingleSignin/Models/Status.cs | 27 + .../Saml/SingleSignin/Models/Subject.cs | 66 + .../SingleSignin/SamlIdpInitiatedEndpoint.cs | 60 + .../SamlIdpInitiatedRequestProcessor.cs | 117 + .../Saml/SingleSignin/SamlNameIdGenerator.cs | 80 + .../SamlSigninCallbackEndpoint.cs | 58 + .../SamlSigninCallbackRequestProcessor.cs | 105 + .../Saml/SingleSignin/SamlSigninEndpoint.cs | 78 + .../SamlSigninRequestProcessor.cs | 197 ++ .../Saml/SingleSignin/SamlSigninResults.cs | 35 + .../SingleSignin/SamlSigninStateIdCookie.cs | 67 + .../EndpointUsageDiagnosticEntry.cs | 30 + ...egisteredImplementationsDiagnosticEntry.cs | 13 + .../Contexts/LogoutNotificationContext.cs | 9 + .../Models/Messages/LogoutRequest.cs | 59 +- .../IdentityServer/Saml/ISamlClaimsMapper.cs | 23 + .../Saml/ISamlFrontChannelLogout.cs | 18 + .../Saml/ISamlInteractionService.cs | 30 + .../Saml/ISamlLogoutNotificationService.cs | 16 + ...ISamlSigninInteractionResponseGenerator.cs | 12 + .../Saml/Models/AuthNRequest.cs | 116 + .../Saml/Models/AuthnContextComparison.cs | 66 + .../Saml/Models/IdpInitiatedResult.cs | 40 + .../Saml/Models/NameIdPolicy.cs | 33 + .../Saml/Models/RequestedAuthnContext.cs | 34 + .../Saml/Models/SamlAttribute.cs | 36 + .../Saml/Models/SamlAuthenticationRequest.cs | 42 + .../Saml/Models/SamlClaimsMappingContext.cs | 21 + .../IdentityServer/Saml/Models/SamlError.cs | 12 + .../Saml/Models/SamlInteractionResponse.cs | 40 + .../Models/SamlInteractionResponseType.cs | 13 + .../Saml/Models/SamlSpSessionData.cs | 64 + .../Saml/Models/SamlStatusCodes.cs | 43 + .../Saml/Models/SamlVersions.cs | 13 + ...DefaultIdentityServerInteractionService.cs | 6 +- .../Services/Default/DefaultUserSession.cs | 49 +- .../IdentityServer/Services/IUserSession.cs | 26 + .../InMemorySamlServiceProviderStore.cs | 47 + .../Default/EndSessionRequestValidator.cs | 20 +- .../EndSessionCallbackValidationResult.cs | 7 + .../Models/ValidatedEndSessionRequest.cs | 8 + .../src/Shared/Telemetry/Tracing.cs | 1 + .../src/Storage/Models/SamlBinding.cs | 22 + .../src/Storage/Models/SamlEndpointType.cs | 22 + .../src/Storage/Models/SamlServiceProvider.cs | 127 + .../src/Storage/Models/SamlSigningBehavior.cs | 38 + .../Stores/ISamlServiceProviderStore.cs | 22 + .../Common/FakeDistributedCache.cs | 62 + .../Endpoints/Saml/CookieHandler.cs | 46 + .../Endpoints/Saml/SamlClaimsMappingTests.cs | 308 ++ .../Endpoints/Saml/SamlData.cs | 26 + .../Endpoints/Saml/SamlDataBuilder.cs | 140 + .../Endpoints/Saml/SamlEncryptionTests.cs | 461 +++ .../Endpoints/Saml/SamlFixture.cs | 287 ++ .../Saml/SamlIdpInitiatedEndpointTests.cs | 387 +++ ...dpointTests.CanReturnMetadata.verified.txt | 20 + .../Saml/SamlMetadataEndpointTests.cs | 233 ++ ...dpoint_should_return_metadata.verified.txt | 1 + .../Saml/SamlSigninCallbackEndpointTests.cs | 475 +++ .../Endpoints/Saml/SamlSigninEndpointTests.cs | 2785 +++++++++++++++++ .../SamlSingleLogoutCallbackEndpointTests.cs | 151 + .../Saml/SamlSingleLogoutEndpointTests.cs | 529 ++++ .../Endpoints/Saml/SamlTestHelpers.cs | 868 +++++ .../Saml/SustainSysSamlTestFixture.cs | 202 ++ .../Endpoints/Saml/SustainSysSigninTests.cs | 107 + .../Endpoints/Saml/TestSamlClaimsMapper.cs | 28 + .../IdentityServer.IntegrationTests.csproj | 3 + .../TestFramework/KestrelTestHost.cs | 78 + .../MockSamlLogoutNotificationService.cs | 19 + .../Common/MockSamlSigningService.cs | 25 + .../Common/MockUserSession.cs | 17 + .../Common/TestSamlClaimsMapper.cs | 28 + .../EndSessionCallbackResultTests.cs | 50 +- .../Results/EndSessionCallbackResultTests.cs | 144 +- ...AuthenticationPropertiesExtensionsTests.cs | 214 ++ .../EndpointOptionsExtensionsTests.cs | 66 + .../Extensions/HttpContextExtensionsTests.cs | 232 +- .../Models/SamlSpSessionDataTests.cs | 177 ++ .../Saml/AuthNRequestParserTests.cs | 155 + .../Saml/LimitedReadStreamTests.cs | 361 +++ .../Saml/LogoutRequestParserTests.cs | 125 + .../Saml/RequestedAuthnContextParsingTests.cs | 217 ++ .../Saml/SamlAssertionEncryptorTests.cs | 316 ++ .../Saml/SamlClaimsServiceTests.cs | 370 +++ ...mlFrontChannelLogoutRequestBuilderTests.cs | 400 +++ .../SamlHttpPostFrontChannelLogoutTests.cs | 67 + ...SamlHttpRedirectFrontChannelLogoutTests.cs | 49 + .../Saml/SamlLogoutCallbackProcessorTests.cs | 273 ++ .../SamlLogoutNotificationServiceTests.cs | 296 ++ .../Saml/SamlProtocolMessageSignerTests.cs | 250 ++ .../Saml/SamlSigningServiceTests.cs | 190 ++ .../Saml/SecureXmlParserTests.cs | 324 ++ .../Saml/XmlSignatureHelperTests.cs | 346 ++ ...ltIdentityServerInteractionServiceTests.cs | 77 + .../EndSessionRequestValidatorTests.cs | 286 ++ products.slnx | 2 + 202 files changed, 20925 insertions(+), 35 deletions(-) create mode 100644 identity-server/clients/src/MvcSaml/.gitignore create mode 100644 identity-server/clients/src/MvcSaml/Controllers/HomeController.cs create mode 100644 identity-server/clients/src/MvcSaml/HostingExtensions.cs create mode 100644 identity-server/clients/src/MvcSaml/MvcSaml.csproj create mode 100644 identity-server/clients/src/MvcSaml/Program.cs create mode 100644 identity-server/clients/src/MvcSaml/Properties/launchSettings.json create mode 100644 identity-server/clients/src/MvcSaml/README.md create mode 100644 identity-server/clients/src/MvcSaml/Views/Home/Index.cshtml create mode 100644 identity-server/clients/src/MvcSaml/Views/Home/Secure.cshtml create mode 100644 identity-server/clients/src/MvcSaml/Views/Shared/_Layout.cshtml create mode 100644 identity-server/clients/src/MvcSaml/Views/_ViewImports.cshtml create mode 100644 identity-server/clients/src/MvcSaml/Views/_ViewStart.cshtml create mode 100644 identity-server/hosts/Shared/Configuration/SamlServiceProviders.cs create mode 100644 identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Saml.cs create mode 100644 identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/SamlOptions.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/DefaultSamlInteractionService.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/EmptySamlServiceProviderStore.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/HttpResponseBindings.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlRequest.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlResultSerializer.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlSigningService.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/LimitedReadStream.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/RedirectResult.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/Result.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.Logging.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlBindingExtensions.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponse.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponseXmlSerializer.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageParser.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageSigner.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestBase.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestError.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestExtractor.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestProcessorBase.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestSignatureValidator.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestValidator.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlResponseSigner.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlSigningService.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlUrlBuilder.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SecureXmlParser.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Infrastructure/XmlSignatureHelper.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/KeyUse.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Log.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Metadata/EntityDescriptorSerializer.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/EntityDescriptor.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/IdpSsoDescriptor.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/KeyDescriptor.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleLogoutService.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleSignOnService.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/Metadata/SamlMetaDataEndpoint.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/NopSamlLogoutNotificationService.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SamlClaimService.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SamlConstants.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SamlResponseBuilder.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Log.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutRequestParser.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutResponseBuilder.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutRequest.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutResponse.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/SamlLogoutRequest.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlFrontChannelLogoutRequestBuilder.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpPostFrontChannelLogout.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpRedirectFrontChannelLogout.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutCallbackProcessor.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutNotificationService.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutRequestProcessor.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutResults.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutCallbackEndpoint.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutEndpoint.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/AuthNRequestParser.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DefaultSamlSigninInteractionResponseGenerator.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DistributedCacheSamlSigninStateStore.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/ISamlSigninStateStore.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Log.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Assertion.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AttributeStatement.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnContext.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnStatement.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthzDecisionStatement.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Conditions.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/DecisionType.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Evidence.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/NameIdentifier.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlAuthenticationState.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlIds.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlResponse.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlSigninRequest.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/StateId.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Status.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Subject.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedEndpoint.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedRequestProcessor.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlNameIdGenerator.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackEndpoint.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackRequestProcessor.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninEndpoint.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninRequestProcessor.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninResults.cs create mode 100644 identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninStateIdCookie.cs create mode 100644 identity-server/src/IdentityServer/Saml/ISamlClaimsMapper.cs create mode 100644 identity-server/src/IdentityServer/Saml/ISamlFrontChannelLogout.cs create mode 100644 identity-server/src/IdentityServer/Saml/ISamlInteractionService.cs create mode 100644 identity-server/src/IdentityServer/Saml/ISamlLogoutNotificationService.cs create mode 100644 identity-server/src/IdentityServer/Saml/ISamlSigninInteractionResponseGenerator.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/AuthNRequest.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/AuthnContextComparison.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/IdpInitiatedResult.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/NameIdPolicy.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/RequestedAuthnContext.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/SamlAttribute.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/SamlAuthenticationRequest.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/SamlClaimsMappingContext.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/SamlError.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponse.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponseType.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/SamlSpSessionData.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/SamlStatusCodes.cs create mode 100644 identity-server/src/IdentityServer/Saml/Models/SamlVersions.cs create mode 100644 identity-server/src/IdentityServer/Stores/InMemory/InMemorySamlServiceProviderStore.cs create mode 100644 identity-server/src/Storage/Models/SamlBinding.cs create mode 100644 identity-server/src/Storage/Models/SamlEndpointType.cs create mode 100644 identity-server/src/Storage/Models/SamlServiceProvider.cs create mode 100644 identity-server/src/Storage/Models/SamlSigningBehavior.cs create mode 100644 identity-server/src/Storage/Stores/ISamlServiceProviderStore.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Common/FakeDistributedCache.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/CookieHandler.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlClaimsMappingTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlData.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlDataBuilder.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlEncryptionTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlFixture.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlIdpInitiatedEndpointTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.CanReturnMetadata.verified.txt create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.metadata_endpoint_should_return_metadata.verified.txt create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninCallbackEndpointTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninEndpointTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutCallbackEndpointTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutEndpointTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlTestHelpers.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSamlTestFixture.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSigninTests.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/TestSamlClaimsMapper.cs create mode 100644 identity-server/test/IdentityServer.IntegrationTests/TestFramework/KestrelTestHost.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Common/MockSamlLogoutNotificationService.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Common/MockSamlSigningService.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Common/TestSamlClaimsMapper.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Extensions/AuthenticationPropertiesExtensionsTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Models/SamlSpSessionDataTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/AuthNRequestParserTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/LimitedReadStreamTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/LogoutRequestParserTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/RequestedAuthnContextParsingTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlAssertionEncryptorTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlClaimsServiceTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlFrontChannelLogoutRequestBuilderTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpPostFrontChannelLogoutTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpRedirectFrontChannelLogoutTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutCallbackProcessorTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutNotificationServiceTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlProtocolMessageSignerTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SamlSigningServiceTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/SecureXmlParserTests.cs create mode 100644 identity-server/test/IdentityServer.UnitTests/Saml/XmlSignatureHelperTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 28e7fb534..3689dd29f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -98,6 +98,7 @@ + diff --git a/identity-server/aspire/AppHosts/All/All.csproj b/identity-server/aspire/AppHosts/All/All.csproj index 77ca2773b..454b3eab2 100644 --- a/identity-server/aspire/AppHosts/All/All.csproj +++ b/identity-server/aspire/AppHosts/All/All.csproj @@ -47,6 +47,7 @@ + diff --git a/identity-server/aspire/AppHosts/All/Program.cs b/identity-server/aspire/AppHosts/All/Program.cs index cf61a4c57..fd091b320 100644 --- a/identity-server/aspire/AppHosts/All/Program.cs +++ b/identity-server/aspire/AppHosts/All/Program.cs @@ -118,6 +118,7 @@ void ConfigureWebClients() RegisterClientIfEnabled("mvc-hybrid-backchannel"); RegisterClientIfEnabled("mvc-jar-jwt"); RegisterClientIfEnabled("mvc-jar-uri-jwt"); + RegisterClientIfEnabled("mvc-saml"); RegisterClientIfEnabled("web"); RegisterTemplateIfEnabled("template-is", 7001); RegisterTemplateIfEnabled("template-is-empty", 7002); diff --git a/identity-server/aspire/AppHosts/All/appsettings.json b/identity-server/aspire/AppHosts/All/appsettings.json index 2951eb626..e58383f61 100644 --- a/identity-server/aspire/AppHosts/All/appsettings.json +++ b/identity-server/aspire/AppHosts/All/appsettings.json @@ -42,6 +42,7 @@ "MvcHybridBackChannel": true, "MvcJarJwt": true, "MvcJarUriJwt": true, + "MvcSaml": true, "Web": true, "WindowsConsoleSystemBrowser": false }, diff --git a/identity-server/clients/src/MvcSaml/.gitignore b/identity-server/clients/src/MvcSaml/.gitignore new file mode 100644 index 000000000..4361d819f --- /dev/null +++ b/identity-server/clients/src/MvcSaml/.gitignore @@ -0,0 +1 @@ +saml-sp.pfx diff --git a/identity-server/clients/src/MvcSaml/Controllers/HomeController.cs b/identity-server/clients/src/MvcSaml/Controllers/HomeController.cs new file mode 100644 index 000000000..0a5eb9767 --- /dev/null +++ b/identity-server/clients/src/MvcSaml/Controllers/HomeController.cs @@ -0,0 +1,23 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Sustainsys.Saml2.AspNetCore2; + +namespace MvcSaml.Controllers; + +public class HomeController : Controller +{ + [AllowAnonymous] + public IActionResult Index() => View(); + + public IActionResult Secure() => View(); + + public IActionResult Logout() => SignOut( + new AuthenticationProperties { RedirectUri = "/" }, + Saml2Defaults.Scheme, + CookieAuthenticationDefaults.AuthenticationScheme); +} diff --git a/identity-server/clients/src/MvcSaml/HostingExtensions.cs b/identity-server/clients/src/MvcSaml/HostingExtensions.cs new file mode 100644 index 000000000..134fcbcc7 --- /dev/null +++ b/identity-server/clients/src/MvcSaml/HostingExtensions.cs @@ -0,0 +1,106 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Authentication.Cookies; +using Sustainsys.Saml2; +using Sustainsys.Saml2.AspNetCore2; +using Sustainsys.Saml2.Configuration; +using Sustainsys.Saml2.Metadata; + +namespace MvcSaml; + +internal static class HostingExtensions +{ + // The SP certificate is used to sign AuthnRequests and LogoutRequests sent to the IdP. + // Generate it with the commands in README.md, then restart both this app and the IdP host. + // Without the certificate, AuthnRequest signing and SP-initiated single logout are unavailable. + private const string SpCertificatePath = "saml-sp.pfx"; + private const string SpCertificatePassword = "changeit"; + + public static WebApplication ConfigureServices(this WebApplicationBuilder builder) + { + // The IdentityServer base URL is injected by Aspire at runtime via the "is-host" environment variable. + var idpBaseUrl = builder.Configuration["is-host"] + ?? throw new InvalidOperationException("is-host configuration is required"); + + builder.Services.AddControllersWithViews(); + + var spCert = LoadSpCertificate(); + + builder.Services.AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = Saml2Defaults.Scheme; + }) + .AddCookie(options => + { + options.Cookie.Name = "mvcsaml"; + }) + .AddSaml2(options => + { + // SP entity ID — must match the EntityId registered in the IdP's SamlServiceProviders config. + // By convention, Sustainsys uses /Saml2 as the entity ID. + options.SPOptions.EntityId = new EntityId("https://localhost:44350/Saml2"); + + // Best practice: require the IdP to sign assertions. + options.SPOptions.WantAssertionsSigned = true; + + if (spCert != null) + { + // Best practice: sign AuthnRequests and LogoutRequests with the SP's certificate. + // The IdP validates the signature using the public key registered in SamlServiceProviders. + options.SPOptions.ServiceCertificates.Add(spCert); + options.SPOptions.AuthenticateRequestSigningBehavior = SigningBehavior.Always; + } + else + { + // No certificate available — AuthnRequest signing and SP-initiated SLO are unavailable. + // See README.md for instructions on generating saml-sp.pfx. + options.SPOptions.AuthenticateRequestSigningBehavior = SigningBehavior.Never; + } + + // Load the IdP configuration from the metadata endpoint published by IdentityServer. + // This automatically picks up signing certificates, endpoints, and capabilities. + options.IdentityProviders.Add( + new IdentityProvider(new EntityId(idpBaseUrl), options.SPOptions) + { + MetadataLocation = $"{idpBaseUrl}/saml/metadata", + LoadMetadata = true + }); + }); + + builder.Services.AddAuthorization(); + + return builder.Build(); + } + + public static WebApplication ConfigurePipeline(this WebApplication app) + { + app.UseDeveloperExceptionPage(); + app.UseHttpsRedirection(); + app.UseStaticFiles(); + + app.UseRouting(); + + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapDefaultControllerRoute() + .RequireAuthorization(); + + return app; + } + + // Returns null if the certificate file has not been generated yet. + // See README.md for generation instructions. + private static X509Certificate2 LoadSpCertificate() + { + if (!File.Exists(SpCertificatePath)) + { + return null; + } + + return X509CertificateLoader.LoadPkcs12FromFile(SpCertificatePath, SpCertificatePassword); + } +} diff --git a/identity-server/clients/src/MvcSaml/MvcSaml.csproj b/identity-server/clients/src/MvcSaml/MvcSaml.csproj new file mode 100644 index 000000000..6544cf0eb --- /dev/null +++ b/identity-server/clients/src/MvcSaml/MvcSaml.csproj @@ -0,0 +1,29 @@ + + + + 3a8b2c1d-4e5f-6a7b-8c9d-0e1f2a3b4c5d + + + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + diff --git a/identity-server/clients/src/MvcSaml/Program.cs b/identity-server/clients/src/MvcSaml/Program.cs new file mode 100644 index 000000000..ff53ab8ef --- /dev/null +++ b/identity-server/clients/src/MvcSaml/Program.cs @@ -0,0 +1,36 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using MvcSaml; +using Serilog; +using Serilog.Events; + +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("MvcSaml", LogEventLevel.Debug) + .Enrich.FromLogContext() + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}") + .CreateLogger(); + +try +{ + var builder = WebApplication + .CreateBuilder(args); + + builder + .AddServiceDefaults(); + + builder + .ConfigureServices() + .ConfigurePipeline() + .Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, messageTemplate: "Unhandled exception"); +} +finally +{ + Log.Information(messageTemplate: "Shut down complete"); + Log.CloseAndFlush(); +} diff --git a/identity-server/clients/src/MvcSaml/Properties/launchSettings.json b/identity-server/clients/src/MvcSaml/Properties/launchSettings.json new file mode 100644 index 000000000..f2d96e3d3 --- /dev/null +++ b/identity-server/clients/src/MvcSaml/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Host": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "https://localhost:44350", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/identity-server/clients/src/MvcSaml/README.md b/identity-server/clients/src/MvcSaml/README.md new file mode 100644 index 000000000..00b513a85 --- /dev/null +++ b/identity-server/clients/src/MvcSaml/README.md @@ -0,0 +1,41 @@ +# MvcSaml + +This client demonstrates SAML 2.0 single sign-on and single logout against IdentityServer. + +## SP Certificate + +The SP certificate is required for three SAML best practices: + +- **Signed AuthnRequests** — the SP signs every authentication request it sends to the IdP, proving the request originated from this SP and has not been tampered with. +- **SP-initiated Single Logout (SLO)** — the SP signs logout requests sent to the IdP. The IdP always requires signed logout requests. +- **Encrypted assertions** — the IdP encrypts assertions using the SP's public key, so assertion content is protected in transit and only this SP can decrypt it. + +Without the certificate, the SSO login flow still works, but AuthnRequest signing is disabled, SP-initiated single logout will fail, and assertions are transmitted in plaintext. + +### Generating the certificate + +Create a self-signed certificate with `openssl`: + +```sh +openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 3650 -nodes -subj "/CN=MvcSaml SP" +openssl pkcs12 -export -out saml-sp.pfx -inkey key.pem -in cert.pem -passout pass:changeit +rm key.pem cert.pem +``` + +Place `saml-sp.pfx` in this project directory (`clients/src/MvcSaml/`). The file is excluded from source control. + +After generating the certificate, **restart both this app and the IdentityServer host** so both sides pick up the new public key. + +### Why both sides need to restart + +The MvcSaml SP reads the certificate at startup to configure request signing and assertion decryption. The IdentityServer host also reads the same certificate file at startup to register the SP's public key for signature validation and assertion encryption. Both must be restarted whenever the certificate is regenerated. + +## Without the certificate + +| Feature | Without certificate | With certificate | +|---|---|---| +| SSO (login) | Works | Works | +| Encrypted assertions | Disabled (plaintext) | Enabled | +| AuthnRequest signing | Disabled | Enabled (always signed) | +| SP-initiated Single Logout | Fails (unsigned logout request rejected by IdP) | Works | +| IdP-initiated Single Logout | Works (IdP signs its own requests) | Works | diff --git a/identity-server/clients/src/MvcSaml/Views/Home/Index.cshtml b/identity-server/clients/src/MvcSaml/Views/Home/Index.cshtml new file mode 100644 index 000000000..2eec34068 --- /dev/null +++ b/identity-server/clients/src/MvcSaml/Views/Home/Index.cshtml @@ -0,0 +1,9 @@ +@{ + ViewData["Title"] = "Home"; +} + +

MvcSaml Sample

+

This sample demonstrates SAML 2.0 single sign-on via Duende IdentityServer using the Sustainsys.Saml2 library.

+

+ Sign in +

diff --git a/identity-server/clients/src/MvcSaml/Views/Home/Secure.cshtml b/identity-server/clients/src/MvcSaml/Views/Home/Secure.cshtml new file mode 100644 index 000000000..a17a3532e --- /dev/null +++ b/identity-server/clients/src/MvcSaml/Views/Home/Secure.cshtml @@ -0,0 +1,28 @@ +@{ + ViewData["Title"] = "Secure"; +} + +

Authenticated User

+

You are signed in via SAML 2.0. Your claims:

+ + + + + + + + + + @foreach (var claim in User.Claims) + { + + + + + } + +
TypeValue
@claim.Type@claim.Value
+ +

+ Logout +

diff --git a/identity-server/clients/src/MvcSaml/Views/Shared/_Layout.cshtml b/identity-server/clients/src/MvcSaml/Views/Shared/_Layout.cshtml new file mode 100644 index 000000000..e3bd55e13 --- /dev/null +++ b/identity-server/clients/src/MvcSaml/Views/Shared/_Layout.cshtml @@ -0,0 +1,36 @@ + + + + + + @ViewData["Title"] - MvcSaml + + + + +
+ @RenderBody() +
+ + diff --git a/identity-server/clients/src/MvcSaml/Views/_ViewImports.cshtml b/identity-server/clients/src/MvcSaml/Views/_ViewImports.cshtml new file mode 100644 index 000000000..d9abef7ec --- /dev/null +++ b/identity-server/clients/src/MvcSaml/Views/_ViewImports.cshtml @@ -0,0 +1,2 @@ +@using MvcSaml +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/identity-server/clients/src/MvcSaml/Views/_ViewStart.cshtml b/identity-server/clients/src/MvcSaml/Views/_ViewStart.cshtml new file mode 100644 index 000000000..820a2f6e0 --- /dev/null +++ b/identity-server/clients/src/MvcSaml/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/identity-server/hosts/Main10/IdentityServerExtensions.cs b/identity-server/hosts/Main10/IdentityServerExtensions.cs index f42ddb663..50481a2f1 100644 --- a/identity-server/hosts/Main10/IdentityServerExtensions.cs +++ b/identity-server/hosts/Main10/IdentityServerExtensions.cs @@ -78,7 +78,9 @@ internal static class IdentityServerExtensions Scope = "openid profile" } ]) - .AddLicenseSummary(); + .AddLicenseSummary() + .AddSaml() + .AddInMemorySamlServiceProviders(SamlServiceProviders.Get()); builder.Services.AddIdentityServerConfiguration(opt => { }) .AddInMemoryClientConfigurationStore(); diff --git a/identity-server/hosts/Shared/Configuration/SamlServiceProviders.cs b/identity-server/hosts/Shared/Configuration/SamlServiceProviders.cs new file mode 100644 index 000000000..63b89e052 --- /dev/null +++ b/identity-server/hosts/Shared/Configuration/SamlServiceProviders.cs @@ -0,0 +1,68 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography.X509Certificates; +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Hosts.Shared.Configuration; + +public static class SamlServiceProviders +{ + // Path to the MvcSaml SP certificate, relative to the host's working directory. + // Must match the file generated by following README.md in the MvcSaml project. + private const string SpCertificatePath = "../../clients/src/MvcSaml/saml-sp.pfx"; + private const string SpCertificatePassword = "changeit"; + + public static IEnumerable Get() + { + // Load the SP certificate once. When present, it enables AuthnRequest signature + // validation and assertion encryption. The same certificate serves both purposes. + var spCert = LoadSpCertificate(); + var spCerts = spCert != null ? new[] { spCert } : []; + + return + [ + new SamlServiceProvider + { + EntityId = "https://localhost:44350/Saml2", + DisplayName = "MvcSaml Sample Client", + Enabled = true, + // ACS URL follows the Sustainsys.Saml2 convention: /Saml2/Acs + AssertionConsumerServiceUrls = [new Uri("https://localhost:44350/Saml2/Acs")], + // SLO URL follows the Sustainsys.Saml2 convention: /Saml2/Logout + SingleLogoutServiceUrl = new SamlEndpointType + { + Location = new Uri("https://localhost:44350/Saml2/Logout"), + Binding = SamlBinding.HttpRedirect + }, + // Sign the assertion (not the response envelope) — the Sustainsys default expectation + SigningBehavior = SamlSigningBehavior.SignAssertion, + // When the SP certificate is present, require signed AuthnRequests, register the + // SP's public key for signature validation, and encrypt assertions with it. + // The same certificate serves both purposes — signing and encryption. + RequireSignedAuthnRequests = spCert != null, + SigningCertificates = spCerts, + EncryptAssertions = spCert != null, + EncryptionCertificates = spCerts, + } + ]; + } + + // Returns null if the certificate file has not been generated yet. + // See README.md in the MvcSaml project for generation instructions. + private static X509Certificate2? LoadSpCertificate() + { + if (!File.Exists(SpCertificatePath)) + { + return null; + } + + // For ease during development, we load the public key directly from the SP's certificate file. + // In a deployed application, you would want to distribute the public key through PKI or + // another secure channel rather than reading the SP's private key material here. +#pragma warning disable SYSLIB0057 + // Only obsolete in .NET 9+; keeping the older API while we support .NET 8. + return new X509Certificate2(SpCertificatePath, SpCertificatePassword); +#pragma warning restore SYSLIB0057 + } +} diff --git a/identity-server/hosts/Shared/Customization/HostProfileService.cs b/identity-server/hosts/Shared/Customization/HostProfileService.cs index 1508e0761..d0c7a1367 100644 --- a/identity-server/hosts/Shared/Customization/HostProfileService.cs +++ b/identity-server/hosts/Shared/Customization/HostProfileService.cs @@ -14,7 +14,7 @@ public class HostProfileService(TestUserStore users, ILogger x.ParsedName == "transaction"); + var transaction = context.RequestedResources?.ParsedScopes.FirstOrDefault(x => x.ParsedName == "transaction"); if (transaction?.ParsedParameter != null) { context.IssuedClaims.Add(new Claim("transaction_id", transaction.ParsedParameter)); diff --git a/identity-server/identity-server.slnf b/identity-server/identity-server.slnf index c33e53e24..03cbaf756 100644 --- a/identity-server/identity-server.slnf +++ b/identity-server/identity-server.slnf @@ -2,6 +2,8 @@ "solution": { "path": "..\\products.slnx", "projects": [ + "conformance-report\\src\\ConformanceReport\\ConformanceReport.csproj", + "conformance-report\\test\\ConformanceReport.Tests\\ConformanceReport.Tests.csproj", "identity-server\\aspire\\AppHosts\\All\\All.csproj", "identity-server\\aspire\\AppHosts\\Dev\\Dev.csproj", "identity-server\\aspire\\ServiceDefaults\\ServiceDefaults.csproj", @@ -38,15 +40,16 @@ "identity-server\\clients\\src\\MvcHybridBackChannel\\MvcHybridBackChannel.csproj", "identity-server\\clients\\src\\MvcJarJwt\\MvcJarJwt.csproj", "identity-server\\clients\\src\\MvcJarUriJwt\\MvcJarUriJwt.csproj", + "identity-server\\clients\\src\\MvcSaml\\MvcSaml.csproj", "identity-server\\clients\\src\\Web\\Web.csproj", "identity-server\\clients\\src\\WindowsConsoleSystemBrowser\\WindowsConsoleSystemBrowser.csproj", + "identity-server\\hosts\\AspNetIdentity10\\Host.AspNetIdentity10.csproj", + "identity-server\\hosts\\EntityFramework10\\Host.EntityFramework10.csproj", + "identity-server\\hosts\\Main10\\Host.Main10.csproj", "identity-server\\hosts\\Shared\\Host.Shared.csproj", "identity-server\\hosts\\UI\\AspNetIdentity\\UI.AspNetIdentity.csproj", "identity-server\\hosts\\UI\\EntityFramework\\UI.EntityFramework.csproj", "identity-server\\hosts\\UI\\Main\\UI.Main.csproj", - "identity-server\\hosts\\AspNetIdentity10\\Host.AspNetIdentity10.csproj", - "identity-server\\hosts\\EntityFramework10\\Host.EntityFramework10.csproj", - "identity-server\\hosts\\Main10\\Host.Main10.csproj", "identity-server\\migrations\\AspNetIdentityDb\\AspNetIdentityDb.csproj", "identity-server\\migrations\\IdentityServerDb\\IdentityServerDb.csproj", "identity-server\\src\\AspNetIdentity\\IdentityServer.AspNetIdentity.csproj", @@ -54,6 +57,7 @@ "identity-server\\src\\Configuration\\IdentityServer.Configuration.csproj", "identity-server\\src\\EntityFramework.Storage\\IdentityServer.EntityFramework.Storage.csproj", "identity-server\\src\\EntityFramework\\IdentityServer.EntityFramework.csproj", + "identity-server\\src\\IdentityServer.ConformanceReport\\IdentityServer.ConformanceReport.csproj", "identity-server\\src\\IdentityServer\\IdentityServer.csproj", "identity-server\\src\\Storage\\IdentityServer.Storage.csproj", "identity-server\\templates\\src\\IdentityServerAspNetIdentity\\IdentityServerAspNetIdentity.csproj", @@ -69,4 +73,4 @@ "shared\\Xunit.Playwright\\Xunit.Playwright.csproj" ] } -} +} \ No newline at end of file diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs index d595bc7fd..b60d47643 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -16,6 +16,7 @@ using Duende.IdentityServer.Hosting; using Duende.IdentityServer.Hosting.DynamicProviders; using Duende.IdentityServer.Hosting.FederatedSignOut; using Duende.IdentityServer.Internal; +using Duende.IdentityServer.Internal.Saml; using Duende.IdentityServer.Licensing; using Duende.IdentityServer.Licensing.V2; using Duende.IdentityServer.Licensing.V2.Diagnostics; @@ -23,6 +24,7 @@ using Duende.IdentityServer.Licensing.V2.Diagnostics.DiagnosticEntries; using Duende.IdentityServer.Logging; using Duende.IdentityServer.Models; using Duende.IdentityServer.ResponseHandling; +using Duende.IdentityServer.Saml; using Duende.IdentityServer.Services; using Duende.IdentityServer.Services.Default; using Duende.IdentityServer.Services.KeyManagement; @@ -210,6 +212,12 @@ public static class IdentityServerBuilderExtensionsCore builder.Services.TryAddTransient(); + // Register no-op SAML services for services used in logout paths + // These are replaced by actual implementations in AddSaml and ISamlServiceProviderStore + // can be replaced with a call to AddSamlServiceProviderStore + builder.Services.TryAddTransient(); + builder.Services.TryAddScoped(); + builder.Services.TryAddSingleton(typeof(IConcurrencyLock<>), typeof(DefaultConcurrencyLock<>)); builder.Services.TryAddTransient(); diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/InMemory.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/InMemory.cs index 40f4fff70..b88d32a69 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/InMemory.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/InMemory.cs @@ -190,4 +190,17 @@ public static class IdentityServerBuilderExtensionsInMemory builder.Services.TryAddSingleton(); return builder; } + + /// + /// Adds the in-memory SAML service provider store. + /// + /// The builder. + /// The SAML service providers. + /// + public static IIdentityServerBuilder AddInMemorySamlServiceProviders(this IIdentityServerBuilder builder, IEnumerable serviceProviders) + { + builder.Services.AddSingleton(serviceProviders); + builder.AddSamlServiceProviderStore(); + return builder; + } } diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Saml.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Saml.cs new file mode 100644 index 000000000..402744c87 --- /dev/null +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Saml.cs @@ -0,0 +1,134 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Internal.Saml; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.Metadata; +using Duende.IdentityServer.Internal.Saml.SingleLogout; +using Duende.IdentityServer.Internal.Saml.SingleLogout.Models; +using Duende.IdentityServer.Internal.Saml.SingleSignin; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Saml; +using Duende.IdentityServer.Stores; +using Microsoft.Extensions.DependencyInjection.Extensions; +using static Duende.IdentityServer.IdentityServerConstants; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Builder extension methods for opting in to SAML 2.0 support. +/// +public static class IdentityServerBuilderExtensionsSaml +{ + /// + /// Adds SAML 2.0 support to IdentityServer. + /// + /// + /// Registers all SAML services and endpoints, and enables the SAML endpoints + /// in . The IdP-initiated SSO endpoint is not + /// enabled by default; set + /// to true explicitly if you need it. + /// + /// The builder. + /// + public static IIdentityServerBuilder AddSaml(this IIdentityServerBuilder builder) + { + builder.AddSamlServices(); + + builder.Services.Configure(options => + { + options.Endpoints.EnableSamlMetadataEndpoint = true; + options.Endpoints.EnableSamlSigninEndpoint = true; + options.Endpoints.EnableSamlSigninCallbackEndpoint = true; + options.Endpoints.EnableSamlLogoutEndpoint = true; + options.Endpoints.EnableSamlLogoutCallbackEndpoint = true; + // EnableSamlIdpInitiatedEndpoint intentionally left false — requires explicit opt-in. + }); + + return builder; + } + + /// + /// Adds SAML 2.0 protocol services. + /// + /// The builder. + /// + private static IIdentityServerBuilder AddSamlServices(this IIdentityServerBuilder builder) + { + // SAML 2.0 endpoints + builder.AddEndpoint(EndpointNames.SamlMetadata, ProtocolRoutePaths.SamlMetadata.EnsureLeadingSlash()); + builder.AddEndpoint(EndpointNames.SamlSignin, ProtocolRoutePaths.SamlSignin.EnsureLeadingSlash()); + builder.AddEndpoint(EndpointNames.SamlSigninCallback, ProtocolRoutePaths.SamlSigninCallback.EnsureLeadingSlash()); + builder.AddEndpoint(EndpointNames.SamlIdpInitiated, ProtocolRoutePaths.SamlIdpInitiated.EnsureLeadingSlash()); + builder.AddEndpoint(EndpointNames.SamlLogout, ProtocolRoutePaths.SamlLogout.EnsureLeadingSlash()); + builder.AddEndpoint(EndpointNames.SamlLogoutCallback, ProtocolRoutePaths.SamlLogoutCallback.EnsureLeadingSlash()); + + // Serializers (Transient) + builder.Services.AddTransient, SamlErrorResponseXmlSerializer>(); + builder.Services.AddTransient, SamlResponse.Serializer>(); + builder.Services.AddTransient, LogoutResponse.Serializer>(); + + // HTTP response writers + builder.AddHttpWriter(); + builder.AddHttpWriter(); + builder.AddHttpWriter(); + + // Processors (Scoped) + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Builders (Scoped) + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Parsers / Extractors (Scoped) + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Infrastructure (Scoped) + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.TryAddScoped(typeof(SamlRequestSignatureValidator<,>)); + + // Interface → Implementation (TryAddScoped for extensibility) + builder.Services.TryAddScoped(); + builder.Services.TryAddScoped(); + // Replace the no-op registered by AddCoreServices with the real implementation. + builder.Services.Replace(ServiceDescriptor.Scoped()); + builder.Services.TryAddScoped(); + + // State management (Singleton) + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + + return builder; + } + + /// + /// Adds a custom SAML service provider store. + /// + /// The type of the implementation. + /// The builder. + /// + public static IIdentityServerBuilder AddSamlServiceProviderStore(this IIdentityServerBuilder builder) + where T : class, ISamlServiceProviderStore + { + builder.Services.AddTransient(); + return builder; + } +} diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/EndpointOptions.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/EndpointOptions.cs index 8e07588a5..78be978f8 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/EndpointOptions.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/EndpointOptions.cs @@ -111,4 +111,52 @@ public class EndpointsOptions /// true if the OAuth 2.0 discovery metadata is enabled; otherwise, false. /// public bool EnableOAuth2MetadataEndpoint { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the SAML metadata endpoint is enabled. + /// + /// + /// true if the SAML metadata endpoint is enabled; otherwise, false. + /// + public bool EnableSamlMetadataEndpoint { get; set; } + + /// + /// Gets or sets a value indicating whether the SAML sign-in (SSO) endpoint is enabled. + /// + /// + /// true if the SAML sign-in endpoint is enabled; otherwise, false. + /// + public bool EnableSamlSigninEndpoint { get; set; } + + /// + /// Gets or sets a value indicating whether the SAML sign-in callback endpoint is enabled. + /// + /// + /// true if the SAML sign-in callback endpoint is enabled; otherwise, false. + /// + public bool EnableSamlSigninCallbackEndpoint { get; set; } + + /// + /// Gets or sets a value indicating whether the SAML IdP-initiated SSO endpoint is enabled. + /// + /// + /// true if the SAML IdP-initiated endpoint is enabled; otherwise, false. + /// + public bool EnableSamlIdpInitiatedEndpoint { get; set; } + + /// + /// Gets or sets a value indicating whether the SAML Single Logout (SLO) endpoint is enabled. + /// + /// + /// true if the SAML logout endpoint is enabled; otherwise, false. + /// + public bool EnableSamlLogoutEndpoint { get; set; } + + /// + /// Gets or sets a value indicating whether the SAML Single Logout callback endpoint is enabled. + /// + /// + /// true if the SAML logout callback endpoint is enabled; otherwise, false. + /// + public bool EnableSamlLogoutCallbackEndpoint { get; set; } } diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/IdentityServerOptions.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/IdentityServerOptions.cs index 93763f48f..5d5961455 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/IdentityServerOptions.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/IdentityServerOptions.cs @@ -293,4 +293,9 @@ public class IdentityServerOptions /// Options that control the diagnostic data that is logged by IdentityServer. /// public DiagnosticOptions Diagnostics { get; set; } = new DiagnosticOptions(); + + /// + /// Gets or sets the SAML 2.0 Identity Provider options. + /// + public SamlOptions Saml { get; set; } = new SamlOptions(); } diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/SamlOptions.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/SamlOptions.cs new file mode 100644 index 000000000..a3842537d --- /dev/null +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/Options/SamlOptions.cs @@ -0,0 +1,155 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using System.Collections.ObjectModel; +using System.Security.Claims; +using Duende.IdentityServer.Models; +using SamlConstants = Duende.IdentityServer.Internal.Saml.SamlConstants; + +namespace Duende.IdentityServer.Configuration; + +/// +/// Options for SAML 2.0 Identity Provider functionality. +/// +public class SamlOptions +{ + /// + /// Gets or sets the metadata validity duration (optional). + /// If set, metadata will include a validUntil attribute. + /// Defaults to 7 days. + /// + public TimeSpan? MetadataValidityDuration { get; set; } = TimeSpan.FromDays(7); + + /// + /// Gets or sets whether the IdP requires signed AuthnRequests. + /// Defaults to false. + /// + public bool WantAuthnRequestsSigned { get; set; } + + /// + /// Default attribute name format to use when SP doesn't specify. + /// Common values: + /// - "urn:oasis:names:tc:SAML:2.0:attrname-format:uri" (for OID format) + /// - "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" (for simple names) + /// Default: Uri (most common) + /// + public string DefaultAttributeNameFormat { get; set; } + = SamlConstants.AttributeNameFormats.Uri; + + /// + /// Default claim type to use when resolving a persistent name identifier based on where + /// the host application has populated the value. Persistent name identifiers will not be + /// generated and are the responsibility of the host application to create. + /// + public string DefaultPersistentNameIdentifierClaimType { get; set; } = ClaimTypes.NameIdentifier; + + /// + /// Default mappings from claim types to SAML attribute names. + /// Key: claim type (e.g., "email", "name") + /// Value: SAML attribute name (e.g., "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name") + /// + /// Includes common OIDC to SAML attribute mappings by default. + /// Service providers can override these mappings via SamlServiceProvider.ClaimMappings. + /// + /// If a claim type is not in this dictionary, the claim will be excluded from the SAML assertion. + /// + public ReadOnlyDictionary DefaultClaimMappings { get; init; } = + new(new Dictionary + { + ["name"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + ["email"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + ["role"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/role", + }); + + /// + /// Gets or sets the supported NameID formats. + /// Defaults to EmailAddress, Persistent, Transient, and Unspecified. + /// + public Collection SupportedNameIdFormats { get; init; } = + [ + SamlConstants.NameIdentifierFormats.EmailAddress, + SamlConstants.NameIdentifierFormats.Persistent, + SamlConstants.NameIdentifierFormats.Transient, + SamlConstants.NameIdentifierFormats.Unspecified + ]; + + /// + /// Gets or sets the default clock skew tolerance for SAML message validation. + /// Defaults to 5 minutes. + /// + public TimeSpan DefaultClockSkew { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the default maximum age for SAML authentication requests. + /// Defaults to 5 minutes. + /// + public TimeSpan DefaultRequestMaxAge { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the default signing behavior for SAML messages. + /// Defaults to . + /// + public SamlSigningBehavior DefaultSigningBehavior { get; set; } = SamlSigningBehavior.SignAssertion; + + /// + /// Maximum length of the RelayState parameter, measured in bytes of its UTF-8 encoding. + /// SAML spec recommends 80 bytes, but can be increased for SPs that support longer values. + /// Default: 80 (UTF-8 bytes). + /// + public int MaxRelayStateLength { get; set; } = 80; + + /// + /// Gets or sets the user interaction options for SAML endpoints. + /// + public SamlUserInteractionOptions UserInteraction { get; set; } = new(); +} + +/// +/// Options for SAML user interaction endpoint paths. +/// +public class SamlUserInteractionOptions +{ + /// + /// Gets or sets the base route for all SAML endpoints. + /// Default: "/saml". + /// + public string Route { get; set; } = SamlConstants.Urls.SamlRoute; + + /// + /// Gets or sets the path for the SAML metadata endpoint. + /// Default: "/metadata". + /// + public string Metadata { get; set; } = SamlConstants.Urls.Metadata; + + /// + /// Gets or sets the path for the SAML sign-in endpoint. + /// Default: "/signin". + /// + public string SignInPath { get; set; } = SamlConstants.Urls.SignIn; + + /// + /// Gets or sets the path for the SAML sign-in callback endpoint. + /// Default: "/signin_callback". + /// + public string SignInCallbackPath { get; set; } = SamlConstants.Urls.SigninCallback; + + /// + /// Gets or sets the path for the IdP-initiated SSO endpoint. + /// Default: "/idp-initiated". + /// + public string IdpInitiatedPath { get; set; } = SamlConstants.Urls.IdpInitiated; + + /// + /// Gets or sets the path for the SAML single logout endpoint. + /// Default: "/logout". + /// + public string SingleLogoutPath { get; set; } = SamlConstants.Urls.SingleLogout; + + /// + /// Gets or sets the path for the SAML single logout callback endpoint. + /// Default: "/logout_callback". + /// + public string SingleLogoutCallbackPath { get; set; } = SamlConstants.Urls.SingleLogoutCallback; +} diff --git a/identity-server/src/IdentityServer/Endpoints/Results/EndSessionCallbackResult.cs b/identity-server/src/IdentityServer/Endpoints/Results/EndSessionCallbackResult.cs index 18ab5395b..836b5c451 100644 --- a/identity-server/src/IdentityServer/Endpoints/Results/EndSessionCallbackResult.cs +++ b/identity-server/src/IdentityServer/Endpoints/Results/EndSessionCallbackResult.cs @@ -9,8 +9,12 @@ using System.Text.Encodings.Web; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Hosting; +using Duende.IdentityServer.Internal.Saml; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Models; using Duende.IdentityServer.Validation; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; namespace Duende.IdentityServer.Endpoints.Results; @@ -34,9 +38,14 @@ public class EndSessionCallbackResult : EndpointResult internal class EndSessionCallbackHttpWriter : IHttpResponseWriter { - public EndSessionCallbackHttpWriter(IdentityServerOptions options) => _options = options; + public EndSessionCallbackHttpWriter(IdentityServerOptions options, ILogger logger) + { + _options = options; + _logger = logger; + } - private IdentityServerOptions _options; + private readonly IdentityServerOptions _options; + private readonly ILogger _logger; public async Task WriteHttpResponse(EndSessionCallbackResult result, HttpContext context) { @@ -59,25 +68,36 @@ internal class EndSessionCallbackHttpWriter : IHttpResponseWriter x.GetOrigin()); - if (origins != null) + var origins = result.Result.FrontChannelLogoutUrls?.Select(x => x.GetOrigin()) ?? []; + origins = origins.Concat(result.Result.SamlFrontChannelLogouts.Select(x => x.Destination.OriginalString)); + foreach (var origin in origins.Distinct()) { - foreach (var origin in origins.Distinct()) + sb.Append(origin); + if (sb.Length > 0) { - sb.Append(origin); - if (sb.Length > 0) - { - sb.Append(' '); - } + sb.Append(' '); } } - // the hash matches the embedded style element being used below - context.Response.AddStyleCspHeaders(_options.Csp, IdentityServerConstants.ContentSecurityPolicyHashes.EndSessionStyle, sb.ToString()); + if (result.Result.SamlFrontChannelLogouts.Any()) + { + // the hash matches the embedded style element being used below + // and the SAML auto-post script hash allows the inline script in the iframe srcdoc + context.Response.AddStyleAndScriptCspHeaders( + _options.Csp, + IdentityServerConstants.ContentSecurityPolicyHashes.EndSessionStyle, + IdentityServerConstants.ContentSecurityPolicyHashes.SamlAutoPostScript, + sb.ToString()); + } + else + { + // the hash matches the embedded style element being used below + context.Response.AddStyleCspHeaders(_options.Csp, IdentityServerConstants.ContentSecurityPolicyHashes.EndSessionStyle, sb.ToString()); + } } } - private static string GetHtml(EndSessionCallbackResult result) + private string GetHtml(EndSessionCallbackResult result) { var sb = new StringBuilder(); sb.Append(""); @@ -91,6 +111,28 @@ internal class EndSessionCallbackHttpWriter : IHttpResponseWriter"); + break; + case SamlBinding.HttpRedirect: + sb.Append(CultureInfo.InvariantCulture, $""); + break; + default: + _logger.LogDebug("Unknown SAML Binding: {SamlBinding}", samlFrontChannelLogout.SamlBinding); + break; + } + + sb.AppendLine(); + } + } + return sb.ToString(); } } diff --git a/identity-server/src/IdentityServer/Extensions/AuthenticationPropertiesExtensions.cs b/identity-server/src/IdentityServer/Extensions/AuthenticationPropertiesExtensions.cs index 1cd60d896..3328df2b1 100644 --- a/identity-server/src/IdentityServer/Extensions/AuthenticationPropertiesExtensions.cs +++ b/identity-server/src/IdentityServer/Extensions/AuthenticationPropertiesExtensions.cs @@ -4,6 +4,7 @@ using System.Buffers.Text; using System.Text; +using Duende.IdentityServer.Saml.Models; using Microsoft.AspNetCore.Authentication; namespace Duende.IdentityServer.Extensions; @@ -15,6 +16,7 @@ public static class AuthenticationPropertiesExtensions { internal const string SessionIdKey = "session_id"; internal const string ClientListKey = "client_list"; + internal const string SamlSessionListKey = "saml_session_list"; /// /// Gets the user's session identifier. @@ -86,7 +88,6 @@ public static class AuthenticationPropertiesExtensions } } - private static IEnumerable DecodeList(string value) { if (value.IsPresent()) @@ -111,4 +112,100 @@ public static class AuthenticationPropertiesExtensions return null; } + + /// + /// Gets the list of SAML SP sessions from the authentication properties. + /// + /// + /// + /// + /// For production deployments with many SAML service providers, enable server-side sessions + /// to avoid cookie size limitations. Without server-side sessions, the practical limit is + /// approximately 5-10 SAML sessions depending on the number of OIDC clients. + /// + public static IEnumerable GetSamlSessionList(this AuthenticationProperties properties) + { + if (properties?.Items.TryGetValue(SamlSessionListKey, out var value) == true && value != null) + { + return DecodeSamlSessionList(value); + } + + return []; + } + + /// + /// Sets the list of SAML SP sessions in the authentication properties. + /// + /// + /// + public static void SetSamlSessionList(this AuthenticationProperties properties, IEnumerable sessions) + { + var value = EncodeSamlSessionList(sessions); + if (value == null) + { + properties.Items.Remove(SamlSessionListKey); + } + else + { + properties.Items[SamlSessionListKey] = value; + } + } + + /// + /// Adds a SAML session to the authentication properties. + /// This is an upsert operation - if a session for the same EntityId already exists, it is replaced. + /// + /// + /// + public static void AddSamlSession(this AuthenticationProperties properties, SamlSpSessionData session) + { + ArgumentNullException.ThrowIfNull(session); + + var sessions = properties.GetSamlSessionList().ToList(); + + // Remove existing session for this SP if present + sessions.RemoveAll(s => s.EntityId == session.EntityId); + + // Add the (potentially updated) session + sessions.Add(session); + properties.SetSamlSessionList(sessions); + } + + /// + /// Removes a SAML session from the authentication properties by EntityId. + /// + /// + /// + public static void RemoveSamlSession(this AuthenticationProperties properties, string entityId) + { + var sessions = properties.GetSamlSessionList() + .Where(s => s.EntityId != entityId) + .ToList(); + + properties.SetSamlSessionList(sessions); + } + + private static SamlSpSessionData[] DecodeSamlSessionList(string value) + { + if (value.IsPresent()) + { + var bytes = Base64Url.DecodeFromChars(value); + var json = Encoding.UTF8.GetString(bytes); + return ObjectSerializer.FromString(json) ?? []; + } + + return []; + } + + private static string EncodeSamlSessionList(IEnumerable list) + { + if (list != null && list.Any()) + { + var json = ObjectSerializer.ToString(list); + var bytes = Encoding.UTF8.GetBytes(json); + return Base64Url.EncodeToString(bytes); + } + + return null; + } } diff --git a/identity-server/src/IdentityServer/Extensions/EndpointOptionsExtensions.cs b/identity-server/src/IdentityServer/Extensions/EndpointOptionsExtensions.cs index 25b617c23..fd98b0601 100644 --- a/identity-server/src/IdentityServer/Extensions/EndpointOptionsExtensions.cs +++ b/identity-server/src/IdentityServer/Extensions/EndpointOptionsExtensions.cs @@ -22,6 +22,12 @@ internal static class EndpointOptionsExtensions IdentityServerConstants.EndpointNames.UserInfo => options.EnableUserInfoEndpoint, IdentityServerConstants.EndpointNames.PushedAuthorization => options.EnablePushedAuthorizationEndpoint, IdentityServerConstants.EndpointNames.BackchannelAuthentication => options.EnableBackchannelAuthenticationEndpoint, + IdentityServerConstants.EndpointNames.SamlMetadata => options.EnableSamlMetadataEndpoint, + IdentityServerConstants.EndpointNames.SamlSignin => options.EnableSamlSigninEndpoint, + IdentityServerConstants.EndpointNames.SamlSigninCallback => options.EnableSamlSigninCallbackEndpoint, + IdentityServerConstants.EndpointNames.SamlIdpInitiated => options.EnableSamlIdpInitiatedEndpoint, + IdentityServerConstants.EndpointNames.SamlLogout => options.EnableSamlLogoutEndpoint, + IdentityServerConstants.EndpointNames.SamlLogoutCallback => options.EnableSamlLogoutCallbackEndpoint, _ => true }; } diff --git a/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs b/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs index 235feb016..60724792a 100644 --- a/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs +++ b/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs @@ -57,23 +57,28 @@ public static class HttpContextExtensions LogoutNotificationContext endSessionMsg = null; // if we have a logout message, then that take precedence over the current user - if (logoutMessage?.ClientIds?.Any() == true) + if (logoutMessage?.ClientIds?.Any() == true || logoutMessage?.SamlSessions?.Any() == true) { - var clientIds = logoutMessage.ClientIds; + var clientIds = logoutMessage.ClientIds ?? []; + var samlSessions = logoutMessage.SamlSessions?.ToList() ?? []; // check if current user is same, since we might have new clients (albeit unlikely) if (currentSubId == logoutMessage.SubjectId) { clientIds = clientIds.Union(await userSession.GetClientListAsync(context.RequestAborted)); + var currentSamlSessions = await userSession.GetSamlSessionListAsync(context.RequestAborted); + samlSessions = samlSessions.Union(currentSamlSessions).ToList(); } - if (await AnyClientHasFrontChannelLogout(logoutMessage.ClientIds)) + var samlEntityIds = samlSessions.Select(s => s.EntityId); + if (await AnyClientHasFrontChannelLogout(logoutMessage.ClientIds) || await AnySamlServiceProviderHasFrontChannelLogout(samlEntityIds, context.RequestAborted)) { endSessionMsg = new LogoutNotificationContext { SubjectId = logoutMessage.SubjectId, SessionId = logoutMessage.SessionId, - ClientIds = clientIds + ClientIds = clientIds, + SamlSessions = samlSessions }; } } @@ -81,13 +86,18 @@ public static class HttpContextExtensions { // see if current user has any clients they need to signout of var clientIds = await userSession.GetClientListAsync(context.RequestAborted); - if (clientIds.Any() && await AnyClientHasFrontChannelLogout(clientIds)) + var samlSessions = await userSession.GetSamlSessionListAsync(context.RequestAborted); + var samlEntityIds = samlSessions.Select(s => s.EntityId); + + if ((clientIds.Any() && await AnyClientHasFrontChannelLogout(clientIds)) || + (samlEntityIds.Any() && await AnySamlServiceProviderHasFrontChannelLogout(samlEntityIds, context.RequestAborted))) { endSessionMsg = new LogoutNotificationContext { SubjectId = currentSubId, SessionId = await userSession.GetSessionIdAsync(context.RequestAborted), - ClientIds = clientIds + ClientIds = clientIds, + SamlSessions = samlSessions }; } } @@ -124,5 +134,20 @@ public static class HttpContextExtensions return false; } + + async Task AnySamlServiceProviderHasFrontChannelLogout(IEnumerable entityIds, Ct ct) + { + var serviceProviderStore = context.RequestServices.GetRequiredService(); + foreach (var entityId in entityIds) + { + var sp = await serviceProviderStore.FindByEntityIdAsync(entityId, ct); + if (sp?.Enabled == true && sp.SingleLogoutServiceUrl != null) + { + return true; + } + } + + return false; + } } } diff --git a/identity-server/src/IdentityServer/Extensions/HttpResponseExtensions.cs b/identity-server/src/IdentityServer/Extensions/HttpResponseExtensions.cs index 29b4d42dd..c0dbe181a 100644 --- a/identity-server/src/IdentityServer/Extensions/HttpResponseExtensions.cs +++ b/identity-server/src/IdentityServer/Extensions/HttpResponseExtensions.cs @@ -98,6 +98,20 @@ public static class HttpResponseExtensions AddCspHeaders(response.Headers, options, cspHeader); } + public static void AddStyleAndScriptCspHeaders(this HttpResponse response, CspOptions options, string styleHash, string scriptHash, string frameSources) + { + var csp1part = options.Level == CspLevel.One ? "'unsafe-inline' " : string.Empty; + + var cspHeader = $"default-src 'none'; style-src {csp1part}'{styleHash}'; script-src {csp1part}'{scriptHash}'"; + + if (!string.IsNullOrEmpty(frameSources)) + { + cspHeader += $"; frame-src {frameSources}"; + } + + AddCspHeaders(response.Headers, options, cspHeader); + } + public static void AddCspHeaders(IHeaderDictionary headers, CspOptions options, string cspHeader) { if (!headers.ContainsKey("Content-Security-Policy")) diff --git a/identity-server/src/IdentityServer/IdentityServerConstants.cs b/identity-server/src/IdentityServer/IdentityServerConstants.cs index 8bf5edc4b..21c9924fa 100644 --- a/identity-server/src/IdentityServer/IdentityServerConstants.cs +++ b/identity-server/src/IdentityServer/IdentityServerConstants.cs @@ -218,6 +218,12 @@ public static class IdentityServerConstants public const string PushedAuthorization = "PushedAuthorization"; public const string OAuthMetadata = "OAuthMetadata"; + public const string SamlMetadata = "SamlMetadata"; + public const string SamlSignin = "SamlSignin"; + public const string SamlSigninCallback = "SamlSigninCallback"; + public const string SamlIdpInitiated = "SamlIdpInitiated"; + public const string SamlLogout = "SamlLogout"; + public const string SamlLogoutCallback = "SamlLogoutCallback"; } public static class ContentSecurityPolicyHashes @@ -236,6 +242,11 @@ public static class IdentityServerConstants /// The hash of the inline script used on the check session endpoint. /// public const string CheckSessionScript = "sha256-jyguj/c+mxOUX7TJrFnIkEQlj4jinO1nejo8qnuF1jc="; + + /// + /// The hash of the inline script used for SAML auto-post form submissions. + /// + public const string SamlAutoPostScript = "sha256-x5thY6OTOhOhd8GSiineDdcCYxqXyCOfbLSHMWmHPjw="; } public static class ProtocolRoutePaths @@ -266,6 +277,14 @@ public static class IdentityServerConstants public const string MtlsIntrospection = MtlsPathPrefix + "/introspect"; public const string MtlsDeviceAuthorization = MtlsPathPrefix + "/deviceauthorization"; + public const string SamlPathPrefix = "saml"; + public const string SamlMetadata = SamlPathPrefix + "/metadata"; + public const string SamlSignin = SamlPathPrefix + "/signin"; + public const string SamlSigninCallback = SamlPathPrefix + "/signin_callback"; + public const string SamlIdpInitiated = SamlPathPrefix + "/idp-initiated"; + public const string SamlLogout = SamlPathPrefix + "/logout"; + public const string SamlLogoutCallback = SamlPathPrefix + "/logout_callback"; + public static readonly string[] CorsPaths = { DiscoveryConfiguration, diff --git a/identity-server/src/IdentityServer/Internal/Saml/DefaultSamlInteractionService.cs b/identity-server/src/IdentityServer/Internal/Saml/DefaultSamlInteractionService.cs new file mode 100644 index 000000000..0a2b1fe35 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/DefaultSamlInteractionService.cs @@ -0,0 +1,77 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Internal.Saml.SingleSignin; +using Duende.IdentityServer.Saml; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Stores; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml; + +internal class DefaultSamlInteractionService( + ISamlSigninStateStore stateStore, + SamlSigninStateIdCookie stateIdCookie, + ISamlServiceProviderStore serviceProviderStore, + ILogger logger) + : ISamlInteractionService +{ + public async Task GetAuthenticationRequestContextAsync(Ct ct = default) + { + using var activity = Tracing.ServiceActivitySource.StartActivity("DefaultSamlInteractionService.GetAuthenticationRequestContext"); + + if (!stateIdCookie.TryGetSamlSigninStateId(out var stateId)) + { + logger.NoSamlAuthenticationStateFound(LogLevel.Warning); + return null; + } + + var state = await stateStore.RetrieveSigninRequestStateAsync(stateId.Value, ct); + if (state == null) + { + logger.StateNotFound(LogLevel.Warning, stateId.Value); + return null; + } + + var sp = await serviceProviderStore.FindByEntityIdAsync(state.ServiceProviderEntityId, ct); + if (sp == null) + { + logger.ServiceProviderNotFound(LogLevel.Warning, state.ServiceProviderEntityId); + return null; + } + + logger.AuthenticationStateLoaded(LogLevel.Debug, sp.EntityId); + + return new SamlAuthenticationRequest + { + ServiceProvider = sp, + AuthNRequest = state.Request, + RelayState = state.RelayState, + IsIdpInitiated = state.IsIdpInitiated + }; + } + + public async Task StoreRequestedAuthnContextResultAsync(bool requestedAuthnContextRequirementsWereMet, Ct ct = default) + { + using var activity = Tracing.ServiceActivitySource.StartActivity("DefaultSamlInteractionService.StoreRequestedAuthnContextResult"); + + if (!stateIdCookie.TryGetSamlSigninStateId(out var stateId)) + { + logger.NoSamlAuthenticationStateFound(LogLevel.Warning); + throw new InvalidOperationException("No active SAML authentication request found. Cannot store authentication error."); + } + + var state = await stateStore.RetrieveSigninRequestStateAsync(stateId.Value, ct); + if (state == null) + { + logger.StateNotFound(LogLevel.Warning, stateId.Value); + throw new InvalidOperationException($"SAML signin state not found for state ID {stateId.Value}"); + } + + state.RequestedAuthnContextRequirementsWereMet = requestedAuthnContextRequirementsWereMet; + await stateStore.UpdateSigninRequestStateAsync(stateId.Value, state, ct); + + logger.RequestedAuthnContextRequirementsWereMetUpdatedInState(LogLevel.Debug, requestedAuthnContextRequirementsWereMet); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/EmptySamlServiceProviderStore.cs b/identity-server/src/IdentityServer/Internal/Saml/EmptySamlServiceProviderStore.cs new file mode 100644 index 000000000..989159e2d --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/EmptySamlServiceProviderStore.cs @@ -0,0 +1,12 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; + +namespace Duende.IdentityServer.Internal.Saml; + +internal class EmptySamlServiceProviderStore : ISamlServiceProviderStore +{ + public Task FindByEntityIdAsync(string entityId, Ct ct) => Task.FromResult(null); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/HttpResponseBindings.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/HttpResponseBindings.cs new file mode 100644 index 000000000..0c052e976 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/HttpResponseBindings.cs @@ -0,0 +1,43 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Text.Encodings.Web; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal static class HttpResponseBindings +{ + internal static string GenerateAutoPostForm(string messageName, string encodedMessage, Uri destination, string? relayState, bool includeCsp = false) + { + var relayStateField = relayState == null + ? string.Empty + : $@""; + + var cspMetaTag = includeCsp + ? $@"" + : string.Empty; + + return $@" + + + + {cspMetaTag} + SAML Response + + + +
+ + {relayStateField} + +
+ + +"; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlRequest.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlRequest.cs new file mode 100644 index 000000000..b09e9b76c --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlRequest.cs @@ -0,0 +1,17 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +/// +/// Interface for SAML requests that have common validation fields +/// +internal interface ISamlRequest +{ + internal static abstract string MessageName { get; } + internal string Issuer { get; } + internal string Version { get; } + internal DateTime IssueInstant { get; } + internal Uri? Destination { get; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlResultSerializer.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlResultSerializer.cs new file mode 100644 index 000000000..3c0fb0c0e --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlResultSerializer.cs @@ -0,0 +1,11 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Xml.Linq; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal interface ISamlResultSerializer +{ + XElement Serialize(T toSerialize); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlSigningService.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlSigningService.cs new file mode 100644 index 000000000..f0b803fae --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/ISamlSigningService.cs @@ -0,0 +1,33 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography.X509Certificates; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +/// +/// Service for obtaining signing credentials for SAML operations. +/// +internal interface ISamlSigningService +{ + /// + /// Gets the X509 certificate used for signing SAML messages. + /// + /// The cancellation token. + /// The signing certificate with private key. + /// + /// Thrown when no signing credential is available, when the credential is not an X509 certificate, + /// or when the certificate does not have a private key. + /// + Task GetSigningCertificateAsync(Ct ct); + + /// + /// Gets the X509 certificate as a base64-encoded string for inclusion in SAML metadata. + /// + /// The cancellation token. + /// Base64-encoded certificate bytes. + /// + /// Thrown when no signing credential is available or when the credential is not an X509 certificate. + /// + Task GetSigningCertificateBase64Async(Ct ct); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/LimitedReadStream.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/LimitedReadStream.cs new file mode 100644 index 000000000..96036c032 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/LimitedReadStream.cs @@ -0,0 +1,56 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal class LimitedReadStream(Stream innerStream, long maxBytes) : Stream +{ + private long _bytesRead; + + public override void Flush() => innerStream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) + { + var bytesToRead = (int)Math.Min(count, maxBytes - _bytesRead); + if (bytesToRead <= 0) + { + throw new InvalidOperationException("Maximum stream size exceeded."); + } + + var read = innerStream.Read(buffer, offset, bytesToRead); + _bytesRead += read; + return read; + } + + public override long Seek(long offset, SeekOrigin origin) => innerStream.Seek(offset, origin); + + public override void SetLength(long value) => innerStream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => innerStream.Write(buffer, offset, count); + + public override bool CanRead => innerStream.CanRead; + + public override bool CanSeek => innerStream.CanSeek; + + public override bool CanWrite => innerStream.CanWrite; + + public override long Length => innerStream.Length; + + public override long Position + { + get => innerStream.Position; + set => innerStream.Position = value; + } + + public override async ValueTask DisposeAsync() + { + await innerStream.DisposeAsync(); + await base.DisposeAsync(); + } + + protected override void Dispose(bool disposing) + { + innerStream.Dispose(); + base.Dispose(disposing); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/RedirectResult.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/RedirectResult.cs new file mode 100644 index 000000000..25b0e36dc --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/RedirectResult.cs @@ -0,0 +1,33 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; +using Duende.IdentityServer.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal class RedirectResult(Uri RedirectUri) : IEndpointResult +{ + public Task ExecuteAsync(HttpContext httpContext) + { + var logger = httpContext.RequestServices.GetRequiredService>(); + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(RedirectUri); + + logger.Redirecting(LogLevel.Trace, RedirectUri); + + httpContext.Response.StatusCode = (int)HttpStatusCode.Redirect; + httpContext.Response.Headers.Location = RedirectUri.ToString(); + + return Task.CompletedTask; + } +} + +internal class ValidationProblemResult(string title, params KeyValuePair[] errors) : IEndpointResult +{ + public async Task ExecuteAsync(HttpContext context) => + await Results.ValidationProblem(new Dictionary(errors), title).ExecuteAsync(context); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/Result.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/Result.cs new file mode 100644 index 000000000..8bc0ccafd --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/Result.cs @@ -0,0 +1,38 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Diagnostics.CodeAnalysis; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal record Result +{ + [MemberNotNullWhen(true, nameof(Value))] + [MemberNotNullWhen(false, nameof(Error))] + internal bool Success { get; private init; } + internal TValue? Value { get; private init; } + internal TFailure? Error { get; private init; } + + public static Result FromValue(TValue value) => + new() + { + Success = true, + Value = value + }; + + public static Result FromError(TFailure error) => + new() + { + Success = false, + Error = error + }; + + public static implicit operator Result(TFailure value) => + FromError(value); + public static implicit operator Result(TValue value) => + FromValue(value); + + // Note: We can't have an implicit operator for TFailure when it's an interface + // because C# won't do double conversion (ConcreteType -> Interface -> Result) +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.Logging.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.Logging.cs new file mode 100644 index 000000000..22af147ea --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.Logging.cs @@ -0,0 +1,66 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal static partial class SamlAssertionEncryptorLoggingExtensions +{ + private static class SamlAssertionEncryptorLogParameters + { + public const string EntityId = "entityId"; + public const string ExpirationDate = "expirationDate"; + public const string ValidFrom = "validFrom"; + public const string KeySize = "keySize"; + public const string ErrorMessage = "errorMessage"; + } + + [LoggerMessage( + EventName = nameof(EncryptingAssertion), + Message = $"Encrypting SAML assertion for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}}" + )] + internal static partial void EncryptingAssertion(this ILogger logger, LogLevel level, string entityId); + + [LoggerMessage( + EventName = nameof(AssertionEncryptedSuccessfully), + Message = $"Successfully encrypted SAML assertion for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}}" + )] + internal static partial void AssertionEncryptedSuccessfully(this ILogger logger, LogLevel level, string entityId); + + [LoggerMessage( + EventName = nameof(CertificateExpired), + Message = $"Encryption certificate for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}} has expired (expiration: {{{SamlAssertionEncryptorLogParameters.ExpirationDate}}})" + )] + internal static partial void CertificateExpired(this ILogger logger, LogLevel level, string entityId, DateTime expirationDate); + + [LoggerMessage( + EventName = nameof(CertificateNotYetValid), + Message = $"Encryption certificate for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}} is not yet valid (valid from: {{{SamlAssertionEncryptorLogParameters.ValidFrom}}})" + )] + internal static partial void CertificateNotYetValid(this ILogger logger, LogLevel level, string entityId, DateTime validFrom); + + [LoggerMessage( + EventName = nameof(CertificateHasNoPublicRsaKey), + Message = $"Encryption certificate for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}} has no public RSA key")] + internal static partial void CertificateHasNoPublicRsaKey(this ILogger logger, LogLevel level, string entityId); + + [LoggerMessage( + EventName = nameof(CertificateWeakKeySize), + Message = $"Encryption certificate for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}} has weak RSA key size ({{{SamlAssertionEncryptorLogParameters.KeySize}}} bits). Minimum required: 2048 bits" + )] + internal static partial void CertificateWeakKeySize(this ILogger logger, LogLevel level, string entityId, int keySize); + + [LoggerMessage( + EventName = nameof(CertificateValidated), + Message = $"Encryption certificate for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}} validated successfully (expires: {{{SamlAssertionEncryptorLogParameters.ExpirationDate}}})" + )] + internal static partial void CertificateValidated(this ILogger logger, LogLevel level, string entityId, DateTime expirationDate); + + [LoggerMessage( + EventName = nameof(FailedToEncryptAssertion), + Level = LogLevel.Error, + Message = $"Failed to encrypt SAML assertion for service provider {{{SamlAssertionEncryptorLogParameters.EntityId}}}: {{{SamlAssertionEncryptorLogParameters.ErrorMessage}}}" + )] + internal static partial void FailedToEncryptAssertion(this ILogger logger, Exception exception, string entityId, string errorMessage); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.cs new file mode 100644 index 000000000..2582bc019 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlAssertionEncryptor.cs @@ -0,0 +1,116 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography.Xml; +using System.Xml; +using Duende.IdentityServer.Models; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal class SamlAssertionEncryptor(TimeProvider timeProvider, ILogger logger) +{ + internal string EncryptAssertion(string responseXml, SamlServiceProvider serviceProvider) + { + ArgumentException.ThrowIfNullOrWhiteSpace(responseXml); + ArgumentNullException.ThrowIfNull(serviceProvider); + + var encryptionCertificate = serviceProvider.EncryptionCertificates?.FirstOrDefault(cert => IsCertificateValid(cert, serviceProvider.EntityId)); + if (encryptionCertificate == null) + { + throw new InvalidOperationException($"No valid encryption certificate found for {serviceProvider.EntityId}. Certificates may be expired, not yet valid, or lacking required RSA keys."); + } + + var doc = SecureXmlParser.LoadXmlDocument(responseXml); + var assertion = FindAssertion(doc); + if (assertion == null) + { + throw new InvalidOperationException($"SAML Response does not contain an Assertion element for {serviceProvider.EntityId}"); + } + + logger.EncryptingAssertion(LogLevel.Debug, serviceProvider.EntityId); + + try + { + var encryptedAssertion = EncryptAssertionXml(assertion, encryptionCertificate, doc); + + ReplaceAssertionWithEncrypted(assertion, encryptedAssertion); + + logger.AssertionEncryptedSuccessfully(LogLevel.Debug, serviceProvider.EntityId); + + return doc.OuterXml; + } + catch (Exception ex) + { + logger.FailedToEncryptAssertion(ex, serviceProvider.EntityId, ex.Message); + throw; + } + } + + private bool IsCertificateValid(X509Certificate2 certificate, string serviceProviderEntityId) + { + var now = timeProvider.GetUtcNow(); + if (certificate.NotAfter < now) + { + logger.CertificateExpired(LogLevel.Error, serviceProviderEntityId, certificate.NotAfter); + return false; + } + + if (certificate.NotBefore > now) + { + logger.CertificateNotYetValid(LogLevel.Error, serviceProviderEntityId, certificate.NotBefore); + return false; + } + + using var publicKey = certificate.GetRSAPublicKey(); + if (publicKey == null) + { + logger.CertificateHasNoPublicRsaKey(LogLevel.Error, serviceProviderEntityId); + return false; + } + + if (publicKey.KeySize < 2048) + { + logger.CertificateWeakKeySize(LogLevel.Error, serviceProviderEntityId, publicKey.KeySize); + return false; + } + + logger.CertificateValidated(LogLevel.Debug, serviceProviderEntityId, certificate.NotAfter); + + return true; + } + + private static XmlElement? FindAssertion(XmlDocument doc) + { + var nsManager = new XmlNamespaceManager(doc.NameTable); + nsManager.AddNamespace("saml", SamlConstants.Namespaces.Assertion); + + return doc.SelectSingleNode("//saml:Assertion", nsManager) as XmlElement; + } + + private static void ReplaceAssertionWithEncrypted(XmlElement originalAssertion, XmlElement encryptedAssertion) + { + var parentNode = originalAssertion.ParentNode; + if (parentNode is null) + { + throw new InvalidOperationException( + "Cannot replace SAML Assertion because it has no parent node in the XML document."); + } + + parentNode.ReplaceChild(encryptedAssertion, originalAssertion); + } + + private static XmlElement EncryptAssertionXml(XmlElement assertion, X509Certificate2 encryptionCertificate, XmlDocument doc) + { + var encryptedXml = new EncryptedXml(); + var encryptedData = encryptedXml.Encrypt(assertion, encryptionCertificate); + + var encryptedAssertion = doc.CreateElement("saml", "EncryptedAssertion", SamlConstants.Namespaces.Assertion); + var encryptedDataElement = doc.ImportNode(encryptedData.GetXml(), true); + encryptedAssertion.AppendChild(encryptedDataElement); + + return encryptedAssertion; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlBindingExtensions.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlBindingExtensions.cs new file mode 100644 index 000000000..09899ec5a --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlBindingExtensions.cs @@ -0,0 +1,34 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal static class SamlBindingExtensions +{ + internal static SamlBinding? FromUrnOrDefault(string? urn) + { + if (urn == null) + { + return null; + } + + return FromUrn(urn); + } + + internal static SamlBinding FromUrn(string urn) => urn switch + { + SamlConstants.Bindings.HttpRedirect => SamlBinding.HttpRedirect, + SamlConstants.Bindings.HttpPost => SamlBinding.HttpPost, + _ => throw new ArgumentOutOfRangeException(nameof(urn), urn, "Unknown SAML binding") + }; + + internal static string ToUrn(this SamlBinding binding) => binding switch + { + SamlBinding.HttpRedirect => SamlConstants.Bindings.HttpRedirect, + SamlBinding.HttpPost => SamlConstants.Bindings.HttpPost, + _ => throw new ArgumentOutOfRangeException(nameof(binding), binding, "Unknown SAML binding") + }; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponse.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponse.cs new file mode 100644 index 000000000..c8e17062d --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponse.cs @@ -0,0 +1,100 @@ + +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Duende.IdentityServer.Endpoints.Results; +using Duende.IdentityServer.Hosting; +using Duende.IdentityServer.Models; +using Microsoft.AspNetCore.Http; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +/// +/// Represents a SAML error response that will be sent to the Service Provider. +/// +internal class SamlErrorResponse : EndpointResult +{ + /// + /// Gets the SAML binding to use for sending the response (HTTP-POST or HTTP-Redirect). + /// + public required SamlBinding Binding { get; init; } + + /// + /// Gets the SAML status code for the error. + /// + public required string StatusCode { get; init; } + + /// + /// Gets the human-readable error message. + /// + public required string Message { get; init; } + + /// + /// Gets the Assertion Consumer Service URL to send the response to. + /// + public required Uri AssertionConsumerServiceUrl { get; init; } + + /// + /// Gets the IdP issuer URI. + /// + public required string Issuer { get; init; } + + /// + /// Gets the request ID this response is replying to (InResponseTo), or null for IdP-initiated. + /// + public string? InResponseTo { get; init; } + + /// + /// Gets the RelayState to preserve across the response. + /// + public string? RelayState { get; init; } + + /// + /// Gets an optional secondary status code for more specific error information. + /// + public string? SubStatusCode { get; init; } + + /// + /// Gets or sets the Service Provider where the response will be sent. + /// + public required SamlServiceProvider ServiceProvider { get; init; } + + internal class ResponseWriter(ISamlResultSerializer serializer) + : IHttpResponseWriter + { + public async Task WriteHttpResponse(SamlErrorResponse result, HttpContext httpContext) + { + var responseElement = serializer.Serialize(result); + + var doc = new XDocument(new XDeclaration("1.0", "UTF-8", null), responseElement); + await using var stringWriter = new StringWriter(); + await using (var xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings + { + OmitXmlDeclaration = false, + Encoding = Encoding.UTF8, + Indent = false, + Async = true + })) + { + doc.Save(xmlWriter); + await xmlWriter.FlushAsync(); + } + + var encodedResponse = Convert.ToBase64String(Encoding.UTF8.GetBytes(stringWriter.ToString())); + + // Generate HTML form that auto-submits to the ACS URL + var html = HttpResponseBindings.GenerateAutoPostForm(SamlConstants.RequestProperties.SAMLResponse, encodedResponse, result.AssertionConsumerServiceUrl, + result.RelayState); + + httpContext.Response.ContentType = "text/html"; + httpContext.Response.Headers.CacheControl = "no-cache, no-store"; + httpContext.Response.Headers.Pragma = "no-cache"; + + await httpContext.Response.WriteAsync(html); + } + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponseXmlSerializer.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponseXmlSerializer.cs new file mode 100644 index 000000000..24fa6341a --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlErrorResponseXmlSerializer.cs @@ -0,0 +1,59 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Globalization; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal class SamlErrorResponseXmlSerializer : ISamlResultSerializer +{ + public XElement Serialize(SamlErrorResponse result) + { + var responseId = SamlIds.NewResponseId(); + var issueInstant = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); + + var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol); + var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion); + + // Build Status element + var statusCodeElement = new XElement(protocolNs + "StatusCode", + new XAttribute("Value", result.StatusCode)); + + // Add sub-status code if provided + if (result.SubStatusCode != null) + { + statusCodeElement.Add( + new XElement(protocolNs + "StatusCode", + new XAttribute("Value", result.SubStatusCode))); + } + + var statusElement = new XElement(protocolNs + "Status", + statusCodeElement); + + // Add status message if provided + if (!string.IsNullOrEmpty(result.Message)) + { + statusElement.Add( + new XElement(protocolNs + "StatusMessage", result.Message)); + } + + // Build Response element + var responseElement = new XElement(protocolNs + "Response", + new XAttribute("ID", responseId), + new XAttribute("Version", "2.0"), + new XAttribute("IssueInstant", issueInstant), + new XAttribute("Destination", result.AssertionConsumerServiceUrl.ToString()), + new XElement(assertionNs + "Issuer", result.Issuer.ToString()), + statusElement); + + // Add InResponseTo if this is a response to a request + if (result.InResponseTo != null) + { + responseElement.Add(new XAttribute("InResponseTo", result.InResponseTo)); + } + + return responseElement; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageParser.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageParser.cs new file mode 100644 index 000000000..d5806d726 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageParser.cs @@ -0,0 +1,76 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Globalization; +using System.Xml.Linq; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +/// +/// Base class for SAML protocol message parsers. +/// Provides common XML parsing and validation utilities. +/// +internal abstract class SamlProtocolMessageParser +{ + protected static string GetRequiredAttribute(XElement element, XName attributeName) + { + var value = element.Attribute(attributeName)?.Value; + if (string.IsNullOrWhiteSpace(value)) + { + throw new FormatException($"Required attribute '{attributeName}' is missing or empty"); + } + + return value; + } + + protected static string? GetOptionalAttribute(XElement element, XName attributeName) + { + var value = element.Attribute(attributeName)?.Value; + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + protected static DateTime ParseDateTime(XElement element, XName attributeName) + { + var value = GetRequiredAttribute(element, attributeName); + if (!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var result)) + { + throw new FormatException($"Invalid DateTime format for attribute '{attributeName}': {value}"); + } + + return result; + } + + protected static bool ParseBooleanAttribute(XElement element, XName attributeName, bool defaultValue) + { + var value = GetOptionalAttribute(element, attributeName); + if (value == null) + { + return defaultValue; + } + + if (bool.TryParse(value, out var result)) + { + return result; + } + + throw new FormatException($"Invalid boolean format for attribute '{attributeName}': {value}"); + } + + protected static string ParseIssuerValue(XElement root, XNamespace assertionNs, string messageType) + { + var issuerElement = root.Element(assertionNs + "Issuer"); + if (issuerElement == null) + { + throw new InvalidOperationException($"Issuer element is required in {messageType}"); + } + + var issuer = issuerElement.Value?.Trim(); + if (string.IsNullOrEmpty(issuer)) + { + throw new InvalidOperationException("Issuer element cannot be empty"); + } + + return issuer; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageSigner.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageSigner.cs new file mode 100644 index 000000000..b5ed433db --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlProtocolMessageSigner.cs @@ -0,0 +1,58 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Xml.Linq; +using Duende.IdentityServer.Models; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal class SamlProtocolMessageSigner( + ISamlSigningService samlSigningService, + ILogger logger) +{ + internal async Task SignProtocolMessage(XElement messageElement, SamlServiceProvider serviceProvider, Ct ct) + { + ArgumentNullException.ThrowIfNull(messageElement); + ArgumentNullException.ThrowIfNull(serviceProvider); + + var certificate = await samlSigningService.GetSigningCertificateAsync(ct); + + logger.SigningSamlProtocolMessage(LogLevel.Debug, serviceProvider.EntityId, messageElement.Name.LocalName); + + try + { + var signedXml = XmlSignatureHelper.SignProtocolElement(messageElement, certificate); + + logger.SuccessfullySignedSamlProtocolMessage(LogLevel.Debug, serviceProvider.EntityId, messageElement.Name.LocalName); + + return signedXml; + } + catch (Exception ex) + { + logger.FailedToSignSamlProtocolMessage(ex, serviceProvider.EntityId, messageElement.Name.LocalName, ex.Message); + throw; + } + } + + internal async Task SignQueryString(string queryString, Ct ct) + { + var certificate = await samlSigningService.GetSigningCertificateAsync(ct); + using var rsa = certificate.GetRSAPrivateKey(); + if (rsa == null) + { + throw new InvalidOperationException("RSA private key not available for signing."); + } + + queryString = $"{queryString}&SigAlg={Uri.EscapeDataString("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256")}"; + + var bytesToSign = Encoding.UTF8.GetBytes(queryString); + + var signature = rsa.SignData(bytesToSign, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + return $"{queryString}&Signature={Uri.EscapeDataString(Convert.ToBase64String(signature))}"; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestBase.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestBase.cs new file mode 100644 index 000000000..c62ce9ef9 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestBase.cs @@ -0,0 +1,30 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Xml.Linq; +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +/// +/// Base record for SAML request wrappers that contain both the parsed request +/// and HTTP binding metadata. +/// +/// The type of the parsed SAML request +internal abstract record SamlRequestBase where TRequest : ISamlRequest +{ + public required TRequest Request { get; init; } + + public required XDocument RequestXml { get; init; } + + public required SamlBinding Binding { get; init; } + + public string? RelayState { get; init; } + + public string? Signature { get; init; } + + public string? SignatureAlgorithm { get; init; } + + public string? EncodedSamlRequest { get; init; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestError.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestError.cs new file mode 100644 index 000000000..3df7f2a07 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestError.cs @@ -0,0 +1,26 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal enum SamlRequestErrorType +{ + Validation, + Protocol +} + +internal class SamlRequestError +{ + internal SamlRequestErrorType Type { get; init; } + internal string? ValidationMessage { get; init; } + internal SamlProtocolError? ProtocolError { get; init; } +} + +internal record SamlProtocolError( + SamlServiceProvider ServiceProvider, + TRequest Request, + SamlError Error); diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestExtractor.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestExtractor.cs new file mode 100644 index 000000000..a2ce6f707 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestExtractor.cs @@ -0,0 +1,167 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.IO.Compression; +using System.Xml; +using System.Xml.Linq; +using Duende.IdentityServer.Models; +using Microsoft.AspNetCore.Http; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +/// +/// Base class for extracting and parsing SAML protocol messages from HTTP requests. +/// Handles common logic for both HTTP-Redirect and HTTP-POST bindings. +/// +/// The type of the parsed SAML request (e.g., AuthNRequest, LogoutRequest) +/// The type of the result containing the parsed request and metadata +internal abstract class SamlRequestExtractor + where TRequest : ISamlRequest + where TResult : SamlRequestBase +{ + private const int MaxRequestSize = 1024 * 1024; // 1MB limit + + protected abstract TRequest ParseRequest(XDocument xmlDocument); + + protected abstract TResult CreateResult( + TRequest parsedRequest, + XDocument requestXml, + SamlBinding binding, + string? relayState, + string? signature = null, + string? signatureAlgorithm = null, + string? encodedSamlRequest = null); + + internal async ValueTask ExtractAsync(HttpContext context) + { + var request = context.Request; + + if (request.Method == HttpMethods.Get) + { + return ExtractRedirectRequest(request); + } + + if (request.Method == HttpMethods.Post) + { + return await ExtractPostBindingRequest(request); + } + + throw new BadHttpRequestException($"Unsupported HTTP method '{request.Method}' for {TRequest.MessageName}"); + } + + private TResult ExtractRedirectRequest(HttpRequest request) + { + var encodedRequest = request.Query[SamlConstants.RequestProperties.SAMLRequest].ToString(); + + if (string.IsNullOrEmpty(encodedRequest)) + { + throw new BadHttpRequestException( + $"Missing '{SamlConstants.RequestProperties.SAMLRequest}' query parameter in {TRequest.MessageName}"); + } + + var relayState = request.Query[SamlConstants.RequestProperties.RelayState].ToString(); + var signature = request.Query[SamlConstants.RequestProperties.Signature].ToString(); + var sigAlg = request.Query[SamlConstants.RequestProperties.SigAlg].ToString(); + + // Normalize empty relay state to null (important for signature validation) + if (string.IsNullOrEmpty(relayState)) + { + relayState = null; + } + + // HTTP-Redirect uses deflate compression + byte[] compressedXmlBytes; + try + { + compressedXmlBytes = Convert.FromBase64String(encodedRequest); + } + catch (FormatException ex) + { + throw new BadHttpRequestException($"Invalid base64 encoding in {TRequest.MessageName}", ex); + } + using var compressedXmlStream = new MemoryStream(compressedXmlBytes); + using var xmlStream = new DeflateStream(compressedXmlStream, CompressionMode.Decompress); + using var limitedStream = new LimitedReadStream(xmlStream, MaxRequestSize); + + var (parsedRequest, xmlDocument) = LoadRequestFromStream(limitedStream); + + return CreateResult( + parsedRequest, + xmlDocument, + SamlBinding.HttpRedirect, + relayState, + string.IsNullOrEmpty(signature) ? null : signature, + string.IsNullOrEmpty(sigAlg) ? null : sigAlg, + encodedRequest); + } + + private async Task ExtractPostBindingRequest(HttpRequest request) + { + if (!request.HasFormContentType) + { + throw new BadHttpRequestException($"POST request does not have form content type for {TRequest.MessageName}"); + } + + var form = await request.ReadFormAsync(); + var encodedRequest = form[SamlConstants.RequestProperties.SAMLRequest].ToString(); + + if (string.IsNullOrEmpty(encodedRequest)) + { + throw new BadHttpRequestException( + $"Missing '{SamlConstants.RequestProperties.SAMLRequest}' form parameter in {TRequest.MessageName}"); + } + + var relayState = form[SamlConstants.RequestProperties.RelayState].ToString(); + + // Normalize empty relay state to null + if (string.IsNullOrEmpty(relayState)) + { + relayState = null; + } + + // HTTP-POST has no compression + byte[] xmlBytes; + try + { + xmlBytes = Convert.FromBase64String(encodedRequest); + } + catch (FormatException ex) + { + throw new BadHttpRequestException($"Invalid base64 encoding in {TRequest.MessageName}", ex); + } + using var xmlStream = new MemoryStream(xmlBytes); + await using var limitedStream = new LimitedReadStream(xmlStream, MaxRequestSize); + + var (parsedRequest, xmlDocument) = LoadRequestFromStream(limitedStream); + + return CreateResult( + parsedRequest, + xmlDocument, + SamlBinding.HttpPost, + relayState); + } + + private (TRequest parsedRequest, XDocument xmlDocument) LoadRequestFromStream(LimitedReadStream limitedStream) + { + try + { + var xmlDocument = SecureXmlParser.LoadXDocument(limitedStream); + var parsedRequest = ParseRequest(xmlDocument); + + return (parsedRequest, xmlDocument); + } + catch (FormatException ex) + { + throw new BadHttpRequestException($"Invalid SAMLRequest format in {TRequest.MessageName}", ex); + } + catch (InvalidOperationException ex) + { + throw new BadHttpRequestException($"Invalid SAMLRequest format in {TRequest.MessageName}", ex); + } + catch (XmlException ex) + { + throw new BadHttpRequestException($"Failed to parse SAMLRequest XML in {TRequest.MessageName}", ex); + } + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestProcessorBase.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestProcessorBase.cs new file mode 100644 index 000000000..8c3505174 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestProcessorBase.cs @@ -0,0 +1,144 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Stores; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal abstract class SamlRequestProcessorBase( + ISamlServiceProviderStore serviceProviderStore, + IOptions options, + SamlRequestValidator requestValidator, + SamlRequestSignatureValidator signatureValidator, + ILogger logger, + string expectedDestination) + where TMessage : ISamlRequest + where TRequest : SamlRequestBase +{ + protected readonly ISamlServiceProviderStore ServiceProviderStore = serviceProviderStore; + protected readonly SamlOptions SamlOptions = options.Value; + protected readonly SamlRequestValidator RequestValidator = requestValidator; + protected readonly SamlRequestSignatureValidator SignatureValidator = signatureValidator; + protected readonly ILogger Logger = logger; + protected readonly string ExpectedDestination = expectedDestination; + + internal async Task>> ProcessAsync(TRequest request, Ct ct = default) + { + var sp = await ServiceProviderStore.FindByEntityIdAsync(request.Request.Issuer, ct); + if (sp?.Enabled != true) + { + Logger.ServiceProviderNotFound(LogLevel.Warning, request.Request.Issuer); + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = $"Service Provider '{request.Request.Issuer}' is not registered or is disabled" + }; + } + + var validationError = ValidateRequest(sp, request); + if (validationError != null) + { + return validationError; + } + + return await ProcessValidatedRequestAsync(sp, request, ct); + } + + private SamlRequestError? ValidateRequest(SamlServiceProvider sp, TRequest request) + { + // Common validation (version, issue instant, destination) + var validationError = RequestValidator.ValidateCommonFields( + request.Request.Version, + request.Request.IssueInstant, + request.Request.Destination, + sp, + ExpectedDestination); + + if (validationError != null) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Protocol, + ProtocolError = new SamlProtocolError(sp, request, new SamlError + { + StatusCode = validationError.StatusCode, + SubStatusCode = validationError.SubStatusCode, + Message = validationError.Message + }) + }; + } + + // Signature validation + var signatureError = ValidateSignature(sp, request); + if (signatureError != null) + { + return signatureError; + } + + // Message-specific validation + return ValidateMessageSpecific(sp, request); + } + + protected abstract bool RequireSignature(SamlServiceProvider sp); + + private SamlRequestError? ValidateSignature(SamlServiceProvider sp, TRequest request) + { + var requireSignature = RequireSignature(sp); + + if (!requireSignature) + { + return null; + } + + if (sp.SigningCertificates == null || sp.SigningCertificates.Count == 0) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = $"Service Provider '{sp.EntityId}' has no signing certificates configured and has sent a {TMessage.MessageName} which requires signature validation" + }; + } + + Result validationResult; + + if (request.Binding == SamlBinding.HttpRedirect) + { + validationResult = SignatureValidator.ValidateRedirectBindingSignature(request, sp); + } + else if (request.Binding == SamlBinding.HttpPost) + { + validationResult = SignatureValidator.ValidatePostBindingSignature(request, sp); + } + else + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Protocol, + ProtocolError = new SamlProtocolError(sp, request, new SamlError + { + StatusCode = SamlStatusCodes.Requester, + Message = $"Unsupported binding for signature validation: {request.Binding}" + }) + }; + } + + if (!validationResult.Success) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Protocol, + ProtocolError = new SamlProtocolError(sp, request, validationResult.Error) + }; + } + + return null; + } + protected abstract SamlRequestError? ValidateMessageSpecific(SamlServiceProvider sp, TRequest request); + protected abstract Task>> ProcessValidatedRequestAsync(SamlServiceProvider sp, TRequest request, Ct ct = default); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestSignatureValidator.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestSignatureValidator.cs new file mode 100644 index 000000000..b9f31b907 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestSignatureValidator.cs @@ -0,0 +1,191 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography.Xml; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +/// +/// Validates signatures on SAML request messages for both HTTP-Redirect and HTTP-POST bindings. +/// +internal class SamlRequestSignatureValidator(TimeProvider timeProvider) + where TRequest : SamlRequestBase + where TSamlRequest : ISamlRequest +{ + private static readonly HashSet SupportedAlgorithms = + [ + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" + ]; + + /// + /// Validates signature on HTTP-Redirect binding request. + /// + internal Result ValidateRedirectBindingSignature( + TRequest request, + SamlServiceProvider serviceProvider) + { + var signature = request.Signature; + var sigAlg = request.SignatureAlgorithm; + + if (string.IsNullOrEmpty(signature)) + { + return Result.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = "Missing signature parameter" }); + } + + if (string.IsNullOrEmpty(sigAlg)) + { + return Result.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = "Missing signature algorithm parameter" }); + } + + if (!SupportedAlgorithms.Contains(sigAlg)) + { + return Result.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = $"Unsupported signature algorithm: {sigAlg}" }); + } + + // re-create the querystring part that is signed. The spec dictates the exact way this is to be done: + // SAMLRequest=value&RelayState=value&SigAlg=value + // The parameters must be URL-encoded + var queryToVerify = $"SAMLRequest={Uri.EscapeDataString(request.EncodedSamlRequest!)}"; + + if (request.RelayState != null) + { + queryToVerify += $"&RelayState={Uri.EscapeDataString(request.RelayState)}"; + } + + queryToVerify += $"&SigAlg={Uri.EscapeDataString(sigAlg)}"; + + var bytesToVerify = Encoding.UTF8.GetBytes(queryToVerify); + var signatureBytes = Convert.FromBase64String(signature); + + return ValidateWithCertificates( + serviceProvider, + cert => ValidateRedirectSignature(cert, bytesToVerify, signatureBytes, sigAlg)); + } + + private static bool ValidateRedirectSignature(X509Certificate2 cert, byte[] data, byte[] signature, string sigAlg) + { + using var rsa = cert.GetRSAPublicKey(); + if (rsa == null) + { + return false; + } + + var hashAlgorithm = sigAlg.Contains("sha512", StringComparison.OrdinalIgnoreCase) + ? HashAlgorithmName.SHA512 + : HashAlgorithmName.SHA256; + + return rsa.VerifyData(data, signature, hashAlgorithm, RSASignaturePadding.Pkcs1); + } + + /// + /// Validates signature on HTTP-POST binding request. + /// + internal Result ValidatePostBindingSignature( + TRequest request, + SamlServiceProvider serviceProvider) + { + var requestXml = request.RequestXml; + + // In order to use SignedXml, we need to work with XmlDocument, not an XDocument. + // So we convert the XDocument to string and then parse it securely into an XmlDocument. + var xmlString = requestXml.ToString(SaveOptions.DisableFormatting); + XmlDocument doc; + + try + { + doc = SecureXmlParser.LoadXmlDocument(xmlString); + } + catch (XmlException ex) + { + return Result.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = $"Invalid XML: {ex.Message}" }); + } + + // Find signature element + var nsmgr = new XmlNamespaceManager(doc.NameTable); + nsmgr.AddNamespace("ds", "http://www.w3.org/2000/09/xmldsig#"); + + var signatureNode = doc.SelectSingleNode("//ds:Signature", nsmgr); + if (signatureNode == null) + { + return Result.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = "Signature element not found" }); + } + + // Get the request ID that must be signed + var requestId = doc.DocumentElement?.GetAttribute("ID"); + if (string.IsNullOrEmpty(requestId)) + { + return Result.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = $"{TSamlRequest.MessageName} missing ID attribute" }); + } + + return ValidateWithCertificates( + serviceProvider, + cert => ValidateXmlSignature(cert, doc, signatureNode, requestId)); + } + + private static bool ValidateXmlSignature(X509Certificate2 cert, XmlDocument doc, XmlNode signatureNode, string expectedId) + { + var signedXml = new SignedXml(doc); + signedXml.LoadXml((XmlElement)signatureNode); + + if (!signedXml.CheckSignature(cert, true)) + { + return false; + } + + // SECURITY: Verify the signature references the request element + var reference = signedXml.SignedInfo?.References.Cast().FirstOrDefault(); + if (reference == null) + { + return false; + } + + var referencedId = reference.Uri?.TrimStart('#'); + return referencedId == expectedId; + } + + private Result ValidateWithCertificates( + SamlServiceProvider serviceProvider, + Func validateSignature) + { + var validCertificates = serviceProvider.SigningCertificates?.Where(cert => ValidateCertificate(cert).Success).ToList(); + if (validCertificates == null || validCertificates.Count == 0) + { + return Result.FromError(new SamlError { StatusCode = SamlStatusCodes.Responder, Message = "No valid certificates configured for service provider" }); + } + + foreach (var cert in validCertificates) + { + if (validateSignature(cert)) + { + return Result.FromValue(true); + } + } + + return Result.FromError(new SamlError { StatusCode = SamlStatusCodes.Requester, Message = "Invalid signature" }); + } + + private Result ValidateCertificate(X509Certificate2 certificate) + { + var now = timeProvider.GetUtcNow(); + + if (certificate.NotBefore > now.UtcDateTime) + { + return Result.FromError($"Certificate is not yet valid (NotBefore: {certificate.NotBefore:u})"); + } + + if (certificate.NotAfter < now.UtcDateTime) + { + return Result.FromError($"Certificate has expired (NotAfter: {certificate.NotAfter:u})"); + } + + return Result.FromValue(true); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestValidator.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestValidator.cs new file mode 100644 index 000000000..017342ff6 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlRequestValidator.cs @@ -0,0 +1,88 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Microsoft.Extensions.Options; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +/// +/// Helper class for common SAML request validation logic +/// +internal class SamlRequestValidator(TimeProvider timeProvider, IOptions options) +{ + private readonly SamlOptions _samlOptions = options.Value; + + /// + /// Validates version, issue instant, and destination for a SAML request + /// + internal SamlValidationError? ValidateCommonFields( + string version, + DateTime issueInstant, + Uri? destination, + SamlServiceProvider serviceProvider, + string expectedDestination) + { + // Version validation + if (version != SamlVersions.V2) + { + return new SamlValidationError + { + Message = "Only Version 2.0 is supported", + StatusCode = SamlStatusCodes.VersionMismatch + }; + } + + var now = timeProvider.GetUtcNow().UtcDateTime; + var clockSkew = serviceProvider.ClockSkew ?? _samlOptions.DefaultClockSkew; + + // Issue instant not in future + if (issueInstant > now.Add(clockSkew)) + { + return new SamlValidationError + { + StatusCode = SamlStatusCodes.Requester, + Message = "Request IssueInstant is in the future" + }; + } + + // Issue instant not too old + var maxAge = serviceProvider.RequestMaxAge ?? _samlOptions.DefaultRequestMaxAge; + if (issueInstant < now.Subtract(maxAge)) + { + return new SamlValidationError + { + StatusCode = SamlStatusCodes.Requester, + Message = "Request has expired (IssueInstant too old)" + }; + } + + // Destination validation + if (destination != null) + { + if (!destination.ToString().Equals(expectedDestination, StringComparison.OrdinalIgnoreCase)) + { + return new SamlValidationError + { + StatusCode = SamlStatusCodes.Requester, + Message = $"Invalid destination. Expected '{expectedDestination}'" + }; + } + } + + return null; + } +} + +/// +/// Represents a SAML validation error +/// +internal class SamlValidationError +{ + internal required string Message { get; init; } + internal string StatusCode { get; init; } = SamlStatusCodes.Requester; + internal string? SubStatusCode { get; init; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlResponseSigner.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlResponseSigner.cs new file mode 100644 index 000000000..cea97b2f7 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlResponseSigner.cs @@ -0,0 +1,57 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Xml.Linq; +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal class SamlResponseSigner( + ISamlSigningService samlSigningService, + IOptions samlOptions, + ILogger logger) +{ + internal async Task SignResponse(XElement responseElement, SamlServiceProvider serviceProvider, Ct ct) + { + var signingBehavior = serviceProvider.SigningBehavior ?? samlOptions.Value.DefaultSigningBehavior; + + if (signingBehavior == SamlSigningBehavior.DoNotSign) + { + logger.SigningDisabledForServiceProvider(LogLevel.Debug, serviceProvider.EntityId); + return responseElement.ToString(SaveOptions.DisableFormatting); + } + + var certificate = await samlSigningService.GetSigningCertificateAsync(ct); + + logger.SigningSamlResponse(LogLevel.Debug, serviceProvider.EntityId, signingBehavior); + + try + { + var signedXml = signingBehavior switch + { + SamlSigningBehavior.SignResponse => + XmlSignatureHelper.SignResponse(responseElement, certificate), + + SamlSigningBehavior.SignAssertion => + XmlSignatureHelper.SignAssertionInResponse(responseElement, certificate), + + SamlSigningBehavior.SignBoth => + XmlSignatureHelper.SignBoth(responseElement, certificate), + + _ => throw new ArgumentException($"Unknown signing behavior: {signingBehavior}") + }; + + logger.SuccessfullySignedSamlResponse(LogLevel.Debug, serviceProvider.EntityId); + + return signedXml; + } + catch (Exception ex) + { + logger.FailedToSignSamlResponse(ex, serviceProvider.EntityId, ex.Message); + throw; + } + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlSigningService.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlSigningService.cs new file mode 100644 index 000000000..578c7edb9 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlSigningService.cs @@ -0,0 +1,72 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography.X509Certificates; +using Duende.IdentityServer.Services; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +/// +/// Default implementation of . +/// +internal class SamlSigningService( + IKeyMaterialService keyMaterialService, + ILogger logger) : ISamlSigningService +{ + /// + public async Task GetSigningCertificateAsync(Ct ct) + { + var credential = await GetSigningCredentialsAsync(ct); + if (!TryExtractCertificateFromCredential(credential, out var certificate)) + { + throw new InvalidOperationException( + "Signing credential must be an X509 certificate with private key."); + } + + if (!certificate.HasPrivateKey) + { + throw new InvalidOperationException( + "Signing certificate must have a private key."); + } + + return certificate; + } + + /// + public async Task GetSigningCertificateBase64Async(Ct ct) + { + var credential = await GetSigningCredentialsAsync(ct); + if (TryExtractCertificateFromCredential(credential, out var certificate)) + { + var certBytes = certificate.Export(X509ContentType.Cert); + return Convert.ToBase64String(certBytes); + } + + throw new InvalidOperationException( + "Signing credential key is not an X509SecurityKey and cannot be used to extract an X509 certificate for SAML metadata."); + } + + private async Task GetSigningCredentialsAsync(Ct ct) + { + var credential = await keyMaterialService.GetSigningCredentialsAsync(null, ct); + return credential ?? throw new InvalidOperationException("No signing credential available. Configure a signing certificate."); + } + + private bool TryExtractCertificateFromCredential(SigningCredentials credential, [NotNullWhen(returnValue: true)] out X509Certificate2? certificate) + { + certificate = null; + if (credential.Key is X509SecurityKey x509Key) + { + certificate = x509Key.Certificate; + return true; + } + + logger.SigningCredentialIsNotX509Certificate(LogLevel.Warning, credential.Key); + + return false; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlUrlBuilder.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlUrlBuilder.cs new file mode 100644 index 000000000..9c17de3c7 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SamlUrlBuilder.cs @@ -0,0 +1,86 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal class SamlUrlBuilder(IServerUrls urls, + IOptions identityServerOptions, + IOptions samlOptions) +{ + private readonly SamlUserInteractionOptions _samlRoutes = samlOptions.Value.UserInteraction; + private readonly UserInteractionOptions _identityServerRoutes = identityServerOptions.Value.UserInteraction; + + internal Uri SamlConsentUri() + { + var consentUrl = _identityServerRoutes.ConsentUrl + ?? throw new InvalidOperationException("No consent url configured"); + + var returnUrlParameter = _identityServerRoutes.ConsentReturnUrlParameter + ?? throw new InvalidOperationException("No Consent return url configured"); + + + return BuildRedirectUrl(consentUrl, returnUrlParameter); + } + + internal Uri SamlLoginUri() + { + var loginPageUrl = _identityServerRoutes.LoginUrl + ?? throw new InvalidOperationException("No login url configured"); + var returnUrlParameter = _identityServerRoutes.LoginReturnUrlParameter + ?? throw new InvalidOperationException("No Login return url configured"); + + + return BuildRedirectUrl(loginPageUrl, returnUrlParameter); + } + + internal Uri SamlLogoutUri(string logoutId) + { + var logoutPageUrl = _identityServerRoutes.LogoutUrl ?? throw new InvalidOperationException("No logout url configured"); + var logoutIdParameter = _identityServerRoutes.LogoutIdParameter ?? throw new InvalidOperationException("No logout id parameter configured"); + + logoutPageUrl = logoutPageUrl.AddQueryString(logoutIdParameter, logoutId); + + return new Uri(logoutPageUrl, logoutPageUrl.IsLocalUrl() ? UriKind.Relative : UriKind.Absolute); + } + + internal Uri SamlSignInCallBackUri() + { + var signInCallBackUrl = _samlRoutes.Route + _samlRoutes.SignInCallbackPath; + + return new Uri(signInCallBackUrl, UriKind.Relative); + } + + internal Uri SamlLogoutCallBackUri() + { + var logoutCallbackUri = _samlRoutes.Route + _samlRoutes.SingleLogoutCallbackPath; + + return new Uri(logoutCallbackUri, UriKind.Relative); + } + + private Uri BuildRedirectUrl(string redirectUrl, string returnUrlParameter) + { + var returnUrl = BuildReturnUrl(); + + var uriKind = UriKind.Relative; + if (!redirectUrl.IsLocalUrl()) + { + // The login page is hosted externally. So, the return url needs to be absolute. + // Since the return url is hosted by us, we can make absolute from the server url. + returnUrl = urls.GetAbsoluteUrl(returnUrl); + uriKind = UriKind.Absolute; + } + + var queryString = new QueryString(); + queryString = queryString.Add(returnUrlParameter, returnUrl); + + return new Uri(redirectUrl + queryString, uriKind); + } + + private string BuildReturnUrl() => _samlRoutes.Route + _samlRoutes.SignInCallbackPath; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SecureXmlParser.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SecureXmlParser.cs new file mode 100644 index 000000000..7dfcd698f --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/SecureXmlParser.cs @@ -0,0 +1,194 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Xml; +using System.Xml.Linq; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +/// +/// Provides secure XML parsing with hardened settings to prevent common XML attacks. +/// +/// +/// This class protects against: +/// - XXE (XML External Entity) attacks +/// - DTD (Document Type Definition) attacks +/// - Billion laughs attack (entity expansion) +/// - Resource exhaustion attacks +/// +internal static class SecureXmlParser +{ + /// + /// Maximum allowed size for SAML messages (1MB). + /// + internal const int MaxMessageSize = 1048576; // 1MB + + /// + /// Secure XML reader settings configured to prevent common XML attacks. + /// + private static readonly XmlReaderSettings SecureSettings = new() + { + // Prohibit DTD processing to prevent DTD-based attacks + DtdProcessing = DtdProcessing.Prohibit, + + // Disable external entity resolution to prevent XXE attacks + XmlResolver = null, + + // Prevent entity expansion attacks (billion laughs) + MaxCharactersFromEntities = 0, + + // Limit document size to prevent resource exhaustion + MaxCharactersInDocument = MaxMessageSize, + + // Ignore comments to prevent comment injection attacks + IgnoreComments = true, + + // Ignore processing instructions to reduce attack surface + IgnoreProcessingInstructions = true, + + // Validate well-formed XML + ConformanceLevel = ConformanceLevel.Document + }; + + internal static XElement LoadXElement(Stream input) + { + try + { + var streamReader = new StreamReader(input); + using var xmlReader = XmlReader.Create(streamReader, SecureSettings); + return XElement.Load(xmlReader); + } + catch (XmlException ex) + { + throw new XmlException( + "Failed to parse XML document with secure settings. " + + "The document may contain prohibited constructs (DTD, external entities) or be malformed.", + ex); + } + } + + /// + /// Loads an XElement from a string with secure settings. + /// + /// The XML string to parse + /// A parsed XElement + /// Thrown when xml is null or empty + /// Thrown when XML is malformed or violates security constraints + internal static XElement LoadXElement(string xml) + { + if (string.IsNullOrEmpty(xml)) + { + throw new ArgumentNullException(nameof(xml), "XML content cannot be null or empty"); + } + + // Check size before parsing + if (xml.Length > MaxMessageSize) + { + throw new XmlException( + $"XML document exceeds maximum allowed size of {MaxMessageSize} bytes. " + + $"Actual size: {xml.Length} bytes."); + } + + try + { + using var stringReader = new StringReader(xml); + using var xmlReader = XmlReader.Create(stringReader, SecureSettings); + + return XElement.Load(xmlReader); + } + catch (XmlException ex) + { + throw new XmlException( + "Failed to parse XML document with secure settings. " + + "The document may contain prohibited constructs (DTD, external entities) or be malformed.", + ex); + } + } + + internal static XDocument LoadXDocument(Stream input) + { + try + { + using var xmlReader = XmlReader.Create(input, SecureSettings); + return XDocument.Load(xmlReader); + } + catch (XmlException ex) + { + throw new XmlException( + "Failed to parse XML document with secure settings. " + + "The document may contain prohibited constructs (DTD, external entities) or be malformed.", + ex); + } + } + + /// + /// Loads an XDocument from a string with secure settings. + /// + /// The XML string to parse + /// A parsed XDocument + /// Thrown when xml is null or empty + /// Thrown when XML is malformed or violates security constraints + internal static XDocument LoadXDocument(string xml) + { + if (string.IsNullOrEmpty(xml)) + { + throw new ArgumentNullException(nameof(xml), "XML content cannot be null or empty"); + } + + // Check size before parsing + if (xml.Length > MaxMessageSize) + { + throw new XmlException( + $"XML document exceeds maximum allowed size of {MaxMessageSize} bytes. " + + $"Actual size: {xml.Length} bytes."); + } + + try + { + using var stringReader = new StringReader(xml); + using var xmlReader = XmlReader.Create(stringReader, SecureSettings); + + return XDocument.Load(xmlReader); + } + catch (XmlException ex) + { + throw new XmlException( + "Failed to parse XML document with secure settings. " + + "The document may contain prohibited constructs (DTD, external entities) or be malformed.", + ex); + } + } + + internal static XmlDocument LoadXmlDocument(string xml) + { + if (string.IsNullOrEmpty(xml)) + { + throw new ArgumentNullException(nameof(xml), "XML content cannot be null or empty"); + } + + if (xml.Length > MaxMessageSize) + { + throw new XmlException( + $"XML document exceeds maximum allowed size of {MaxMessageSize} bytes. " + + $"Actual size: {xml.Length} bytes."); + } + + try + { + using var stringReader = new StringReader(xml); + using var xmlReader = XmlReader.Create(stringReader, SecureSettings); + + var doc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null }; + doc.Load(xmlReader); + + return doc; + } + catch (XmlException ex) + { + throw new XmlException( + "Failed to parse XML document with secure settings. " + + "The document may contain prohibited constructs (DTD, external entities) or be malformed.", + ex); + } + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/XmlSignatureHelper.cs b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/XmlSignatureHelper.cs new file mode 100644 index 000000000..9d174f0c5 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Infrastructure/XmlSignatureHelper.cs @@ -0,0 +1,181 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography.Xml; +using System.Xml; +using System.Xml.Linq; + +namespace Duende.IdentityServer.Internal.Saml.Infrastructure; + +internal static class XmlSignatureHelper +{ + internal static string SignResponse(XElement responseElement, X509Certificate2 certificate) + { + var xmlDoc = ConvertToXmlDocument(responseElement); + + var docElement = xmlDoc.DocumentElement; + if (docElement?.LocalName != "Response") + { + throw new ArgumentException("XML must contain a Response element"); + } + + SignElement(xmlDoc, docElement, certificate); + return xmlDoc.OuterXml; + } + + internal static string SignProtocolElement(XElement protocolElement, X509Certificate2 certificate) + { + var xmlDoc = ConvertToXmlDocument(protocolElement); + + var docElement = xmlDoc.DocumentElement; + if (docElement == null) + { + throw new ArgumentException("XML must contain a root element"); + } + + SignElement(xmlDoc, docElement, certificate); + return xmlDoc.OuterXml; + } + + internal static string SignAssertionInResponse(XElement responseElement, X509Certificate2 certificate) + { + var xmlDoc = ConvertToXmlDocument(responseElement); + + // Find the Assertion element + var nsmgr = new XmlNamespaceManager(xmlDoc.NameTable); + nsmgr.AddNamespace("saml", SamlConstants.Namespaces.Assertion); + nsmgr.AddNamespace("samlp", SamlConstants.Namespaces.Protocol); + + var assertionNode = xmlDoc.SelectSingleNode("//saml:Assertion", nsmgr); + if (assertionNode is not XmlElement assertionElement) + { + throw new ArgumentException("Response must contain an Assertion element"); + } + + SignElement(xmlDoc, assertionElement, certificate); + return xmlDoc.OuterXml; + } + + internal static string SignBoth(XElement responseElement, X509Certificate2 certificate) + { + // First sign the assertion + var xmlAfterAssertionSigned = SignAssertionInResponse(responseElement, certificate); + + // Convert back to XElement and then sign the response + var xmlDoc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null }; + xmlDoc.LoadXml(xmlAfterAssertionSigned); + + var docElement = xmlDoc.DocumentElement; + if (docElement?.LocalName != "Response") + { + throw new ArgumentException("XML must contain a Response element"); + } + + SignElement(xmlDoc, docElement, certificate); + return xmlDoc.OuterXml; + } + + private static void SignElement( + XmlDocument xmlDoc, + XmlElement elementToSign, + X509Certificate2 certificate) + { + // Validate element has ID attribute (required for SAML signing) + var idAttribute = elementToSign.GetAttribute("ID"); + if (string.IsNullOrEmpty(idAttribute)) + { + throw new ArgumentException("Element to sign must have an ID attribute"); + } + + // Get private key + var privateKey = certificate.GetRSAPrivateKey(); + if (privateKey == null) + { + throw new CryptographicException("Cannot get private key from certificate"); + } + + // Create a custom SignedXml that knows how to resolve ID attributes + var signedXml = new SignedXml(xmlDoc) + { + SigningKey = privateKey + }; + + // Set canonicalization method for SignedInfo + signedXml.SignedInfo!.CanonicalizationMethod = SignedXml.XmlDsigExcC14NTransformUrl; + signedXml.SignedInfo.SignatureMethod = SignedXml.XmlDsigRSASHA256Url; + + // Create reference to the element (using its ID) + var reference = new Reference($"#{idAttribute}") + { + DigestMethod = SignedXml.XmlDsigSHA256Url + }; + + // Add transforms + reference.AddTransform(new XmlDsigEnvelopedSignatureTransform()); + reference.AddTransform(new XmlDsigExcC14NTransform()); + + signedXml.AddReference(reference); + + // Add certificate to KeyInfo + var keyInfo = new KeyInfo(); + keyInfo.AddClause(new KeyInfoX509Data(certificate)); + signedXml.KeyInfo = keyInfo; + + // Compute signature + signedXml.ComputeSignature(); + + // Get signature element + var signatureElement = signedXml.GetXml(); + + // Insert signature after Issuer element (per SAML spec) + InsertSignatureAfterIssuer(elementToSign, signatureElement); + } + + /// + /// Inserts signature element after Issuer element (SAML requirement) + /// + private static void InsertSignatureAfterIssuer( + XmlElement parentElement, + XmlElement signatureElement) + { + // Find Issuer element + var issuerElement = parentElement.SelectSingleNode("*[local-name()='Issuer']"); + + if (issuerElement != null && issuerElement.NextSibling != null) + { + // Insert after Issuer + parentElement.InsertAfter(signatureElement, issuerElement); + } + else + { + // No Issuer or no next sibling - insert as first child + if (parentElement.FirstChild != null) + { + parentElement.InsertBefore(signatureElement, parentElement.FirstChild); + } + else + { + parentElement.AppendChild(signatureElement); + } + } + } + + /// + /// Converts XElement to XmlDocument, preserving namespace prefixes + /// + private static XmlDocument ConvertToXmlDocument(XElement element) + { + var xmlDoc = new XmlDocument + { + PreserveWhitespace = true, // Important for signatures + XmlResolver = null // Disable external entity resolution (XXE protection) + }; + + using var reader = element.CreateReader(); + xmlDoc.Load(reader); + + return xmlDoc; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/KeyUse.cs b/identity-server/src/IdentityServer/Internal/Saml/KeyUse.cs new file mode 100644 index 000000000..a1572633a --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/KeyUse.cs @@ -0,0 +1,20 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Internal.Saml; + +/// +/// Represents the usage type of a SAML key descriptor. +/// +internal enum KeyUse +{ + /// + /// Key used for signing. + /// + Signing, + + /// + /// Key used for encryption. + /// + Encryption +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Log.cs b/identity-server/src/IdentityServer/Internal/Saml/Log.cs new file mode 100644 index 000000000..f1517057c --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Log.cs @@ -0,0 +1,192 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Models; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace Duende.IdentityServer.Internal.Saml; + +internal static class SamlLogParameters +{ + internal const string RedirectUrl = "redirectUrl"; + internal const string EntityId = "entityId"; + internal const string SamlSigningBehavior = "samlSigningBehavior"; + internal const string ErrorMessage = "errorMessage"; + internal const string SecurityKey = "securityKey"; + internal const string RequestedAuthnContextRequirementsWereMet = "requestedAuthnContextRequirementsWereMet"; + internal const string ClaimCount = "claimCount"; + internal const string AttributeCount = "attributeCount"; + internal const string MessageType = "messageType"; +} + +internal static partial class Log +{ + [LoggerMessage( + EventName = nameof(Redirecting), + Message = $"Redirecting to {{{SamlLogParameters.RedirectUrl}}}" + )] + internal static partial void Redirecting(this ILogger logger, LogLevel level, Uri redirectUrl); + + [LoggerMessage( + EventName = nameof(StartSamlSigninRequest), + Message = $"Starting Saml Signin request" + )] + internal static partial void StartSamlSigninRequest(this ILogger logger, LogLevel level); + + [LoggerMessage( + EventName = nameof(StartSamlSigninCallbackRequest), + Message = $"Starting Saml Signin Callback request" + )] + internal static partial void StartSamlSigninCallbackRequest(this ILogger logger, LogLevel level); + + [LoggerMessage( + EventName = nameof(SamlInteractionPassiveAndForced), + Message = $"AuthN request asks for both passive and forced. This is not supported, so returning 'nopassive'" + )] + internal static partial void SamlInteractionPassiveAndForced(this ILogger logger, LogLevel level); + + [LoggerMessage( + EventName = nameof(SamlInteractionForced), + Message = $"AuthN request asks for forced. User is already authenticated, so signing out user and triggering new login." + )] + internal static partial void SamlInteractionForced(this ILogger logger, LogLevel level); + + [LoggerMessage( + EventName = nameof(SamlInteractionAlreadyAuthenticated), + Message = $"AuthN request asked for Passive. User is already authenticated, so triggering callback." + )] + internal static partial void SamlInteractionAlreadyAuthenticated(this ILogger logger, LogLevel level); + + [LoggerMessage( + EventName = nameof(SamlInteractionNoPassive), + Message = $"AuthN request asks for passive. User is not authenticated, so returning error 'NoPassive'" + )] + internal static partial void SamlInteractionNoPassive(this ILogger logger, LogLevel level); + + [LoggerMessage( + EventName = nameof(SamlInteractionConsent), + Message = $"ServiceProvider is configured to require consent. The AuthN request indicates that consent hasn't already been provided, so triggering consent screen." + )] + internal static partial void SamlInteractionConsent(this ILogger logger, LogLevel level); + + [LoggerMessage( + EventName = nameof(SamlInteractionLogin), + Message = $"AuthN request asks for login. User is not authenticated, so triggering login." + )] + internal static partial void SamlInteractionLogin(this ILogger logger, LogLevel level); + + [LoggerMessage( + EventName = nameof(SigningDisabledForServiceProvider), + Message = $"Signing disabled for SP {{{SamlLogParameters.EntityId}}}")] + internal static partial void SigningDisabledForServiceProvider(this ILogger logger, LogLevel level, string entityId); + + [LoggerMessage( + EventName = nameof(SigningSamlResponse), + Message = $"Signing SAML message for SP {{{SamlLogParameters.EntityId}}} with signing behavior {{{SamlLogParameters.SamlSigningBehavior}}}")] + internal static partial void SigningSamlResponse(this ILogger logger, LogLevel level, string entityId, SamlSigningBehavior samlSigningBehavior); + + [LoggerMessage( + EventName = nameof(SuccessfullySignedSamlResponse), + Message = $"Successfully signed SAML message for SP {{{SamlLogParameters.EntityId}}}")] + internal static partial void SuccessfullySignedSamlResponse(this ILogger logger, LogLevel level, string entityId); + + [LoggerMessage( + EventName = nameof(FailedToSignSamlResponse), + Level = LogLevel.Error, + Message = $"Failed to sign SAML Response for SP {{{SamlLogParameters.EntityId}}}: {{{SamlLogParameters.ErrorMessage}}}")] + internal static partial void FailedToSignSamlResponse(this ILogger logger, Exception ex, string entityId, string errorMessage); + + [LoggerMessage( + EventName = nameof(SigningCredentialIsNotX509Certificate), + Message = $"Signing credential is not an X509 certificate (Key: {{{SamlLogParameters.SecurityKey}}}). SAML signing requires X509 certificates with private keys.")] + internal static partial void SigningCredentialIsNotX509Certificate(this ILogger logger, LogLevel level, SecurityKey securityKey); + + [LoggerMessage( + EventName = nameof(StateNotFound), + Message = "SAML signin state not found for state ID {StateId}")] + internal static partial void StateNotFound(this ILogger logger, LogLevel level, StateId stateId); + + [LoggerMessage( + EventName = nameof(ServiceProviderNotFound), + Message = $"Service Provider {{{SamlLogParameters.EntityId}}} not found")] + internal static partial void ServiceProviderNotFound(this ILogger logger, LogLevel level, string entityId); + + [LoggerMessage( + EventName = nameof(NoSamlAuthenticationStateFound), + Message = "Cannot load SAML authentication state.")] + internal static partial void NoSamlAuthenticationStateFound(this ILogger logger, LogLevel level); + + [LoggerMessage( + EventName = nameof(AuthenticationStateLoaded), + Message = $"SAML authentication request context loaded for SP {{{SamlLogParameters.EntityId}}}")] + internal static partial void AuthenticationStateLoaded(this ILogger logger, LogLevel level, string entityId); + + [LoggerMessage( + EventName = nameof(RequestedAuthnContextRequirementsWereMetUpdatedInState), + Message = $"Stored requestedAuthnContextRequirementsWereMet for SAML request: {{{SamlLogParameters.RequestedAuthnContextRequirementsWereMet}}}")] + internal static partial void RequestedAuthnContextRequirementsWereMetUpdatedInState(this ILogger logger, + LogLevel level, bool requestedAuthnContextRequirementsWereMet); + + [LoggerMessage( + EventName = nameof(StartIdpInitiatedRequest), + Message = "Starting IdP-initiated SAML request for SP '{serviceProviderEntityId}'")] + internal static partial void StartIdpInitiatedRequest(this ILogger logger, LogLevel level, string serviceProviderEntityId); + + [LoggerMessage( + EventName = nameof(IdpInitiatedRequestFailed), + Message = "IdP-initiated SAML request failed: {ErrorMessage}")] + internal static partial void IdpInitiatedRequestFailed(this ILogger logger, LogLevel level, string errorMessage); + + [LoggerMessage( + EventName = nameof(IdpInitiatedRequestSuccess), + Message = "IdP-initiated SAML request succeeded, redirecting to {RedirectUrl}")] + internal static partial void IdpInitiatedRequestSuccess(this ILogger logger, LogLevel level, Uri redirectUrl); + + [LoggerMessage( + EventName = nameof(RetrievedClaimsFromProfileService), + Message = $"Retrieved {{{SamlLogParameters.ClaimCount}}} claims from profile service")] + internal static partial void RetrievedClaimsFromProfileService(this ILogger logger, LogLevel level, int claimCount); + + [LoggerMessage( + EventName = nameof(UsingCustomClaimMapper), + Message = $"Using custom claim mapper for SP {{{SamlLogParameters.EntityId}}}")] + internal static partial void UsingCustomClaimMapper(this ILogger logger, LogLevel level, string entityId); + + [LoggerMessage( + EventName = nameof(MappedClaimsToAttributes), + Message = $"Mapped {{{SamlLogParameters.ClaimCount}}} claims to {{{SamlLogParameters.AttributeCount}}} SAML attributes for SP {{{SamlLogParameters.EntityId}}}")] + internal static partial void MappedClaimsToAttributes(this ILogger logger, LogLevel level, int claimCount, int attributeCount, string entityId); + + [LoggerMessage( + EventName = nameof(SigningSamlProtocolMessage), + Message = $"Signing SAML protocol message ({{{SamlLogParameters.MessageType}}}) for SP {{{SamlLogParameters.EntityId}}}")] + internal static partial void SigningSamlProtocolMessage(this ILogger logger, LogLevel level, string entityId, string messageType); + + [LoggerMessage( + EventName = nameof(SuccessfullySignedSamlProtocolMessage), + Message = $"Successfully signed SAML protocol message ({{{SamlLogParameters.MessageType}}}) for SP {{{SamlLogParameters.EntityId}}}")] + internal static partial void SuccessfullySignedSamlProtocolMessage(this ILogger logger, LogLevel level, string entityId, string messageType); + + [LoggerMessage( + EventName = nameof(FailedToSignSamlProtocolMessage), + Level = LogLevel.Error, + Message = $"Failed to sign SAML protocol message ({{{SamlLogParameters.MessageType}}}) for SP {{{SamlLogParameters.EntityId}}}: {{{SamlLogParameters.ErrorMessage}}}")] + internal static partial void FailedToSignSamlProtocolMessage(this ILogger logger, Exception ex, string entityId, string messageType, string errorMessage); + + [LoggerMessage( + EventName = nameof(SamlSigninSuccess), + Message = $"SAML signin request processed successfully, redirecting to {{{SamlLogParameters.RedirectUrl}}}")] + internal static partial void SamlSigninSuccess(this ILogger logger, LogLevel level, Uri redirectUrl); + + [LoggerMessage( + EventName = nameof(SamlSigninValidationError), + Message = $"SAML signin validation error: {{{SamlLogParameters.ErrorMessage}}}")] + internal static partial void SamlSigninValidationError(this ILogger logger, LogLevel level, string errorMessage); + + [LoggerMessage( + EventName = nameof(SamlSigninProtocolError), + Message = $"SAML signin protocol error: {{statusCode}} - {{{SamlLogParameters.ErrorMessage}}}")] + internal static partial void SamlSigninProtocolError(this ILogger logger, LogLevel level, string statusCode, string errorMessage); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/EntityDescriptorSerializer.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/EntityDescriptorSerializer.cs new file mode 100644 index 000000000..a97b10b3c --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/EntityDescriptorSerializer.cs @@ -0,0 +1,116 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Globalization; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.Metadata.Models; + +namespace Duende.IdentityServer.Internal.Saml.Metadata; + +/// +/// Serializes SAML metadata EntityDescriptor to XML. +/// +internal static class EntityDescriptorSerializer +{ + private static readonly XNamespace MdNamespace = SamlConstants.Namespaces.Metadata; + private static readonly XNamespace DsNamespace = SamlConstants.Namespaces.XmlSignature; + + /// + /// Serializes an EntityDescriptor to SAML metadata XML string. + /// + /// The entity descriptor to serialize. + /// XML string representing the SAML metadata. + internal static XDocument SerializeToXml(EntityDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + + var root = new XElement(MdNamespace + SamlConstants.MetadataElements.EntityDescriptor, + new XAttribute(SamlConstants.MetadataAttributes.EntityId, descriptor.EntityId)); + + // Add validUntil if specified + if (descriptor.ValidUntil.HasValue) + { + root.Add(new XAttribute(SamlConstants.MetadataAttributes.ValidUntil, + descriptor.ValidUntil.Value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture))); + } + + // Add IDPSSODescriptor if present + if (descriptor.IdpSsoDescriptor != null) + { + root.Add(SerializeIdpSsoDescriptor(descriptor.IdpSsoDescriptor)); + } + + return new XDocument( + new XDeclaration("1.0", "UTF-8", null), + root); + } + + private static XElement SerializeIdpSsoDescriptor(IdpSsoDescriptor descriptor) + { + var element = new XElement(MdNamespace + SamlConstants.MetadataElements.IdpSsoDescriptor, + new XAttribute(SamlConstants.MetadataAttributes.ProtocolSupportEnumeration, + descriptor.ProtocolSupportEnumeration)); + + // Add WantAuthnRequestsSigned if true + if (descriptor.WantAuthnRequestsSigned) + { + element.Add(new XAttribute(SamlConstants.MetadataAttributes.WantAuthnRequestsSigned, "true")); + } + + // Add KeyDescriptors + foreach (var keyDescriptor in descriptor.KeyDescriptors) + { + element.Add(SerializeKeyDescriptor(keyDescriptor)); + } + + // Add NameIDFormats + foreach (var nameIdFormat in descriptor.NameIdFormats) + { + element.Add(new XElement(MdNamespace + SamlConstants.MetadataElements.NameIdFormat, nameIdFormat)); + } + + // Add SingleSignOnServices + foreach (var ssoService in descriptor.SingleSignOnServices) + { + element.Add(SerializeSingleSignOnService(ssoService)); + } + + // Add SingleLogoutServices + foreach (var sloService in descriptor.SingleLogoutServices) + { + element.Add(SerializeSingleLogoutService(sloService)); + } + + return element; + } + + private static XElement SerializeKeyDescriptor(KeyDescriptor descriptor) + { + var element = new XElement(MdNamespace + SamlConstants.MetadataElements.KeyDescriptor); + + // Add use attribute if specified + if (descriptor.Use.HasValue) + { + element.Add(new XAttribute(SamlConstants.MetadataAttributes.Use, SamlConstants.MetadataAttributes.ToString(descriptor.Use.Value))); + } + + // Add KeyInfo with X509Data + var keyInfo = new XElement(DsNamespace + SamlConstants.MetadataElements.KeyInfo, + new XElement(DsNamespace + SamlConstants.MetadataElements.X509Data, + new XElement(DsNamespace + SamlConstants.MetadataElements.X509Certificate, + descriptor.X509Certificate))); + + element.Add(keyInfo); + + return element; + } + + private static XElement SerializeSingleSignOnService(SingleSignOnService service) => new(MdNamespace + SamlConstants.MetadataElements.SingleSignOnService, + new XAttribute(SamlConstants.MetadataAttributes.Binding, service.Binding.ToUrn()), + new XAttribute(SamlConstants.MetadataAttributes.Location, service.Location)); + + private static XElement SerializeSingleLogoutService(SingleLogoutService service) => new(MdNamespace + SamlConstants.MetadataElements.SingleLogoutService, + new XAttribute(SamlConstants.MetadataAttributes.Binding, service.Binding.ToUrn()), + new XAttribute(SamlConstants.MetadataAttributes.Location, service.Location)); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/EntityDescriptor.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/EntityDescriptor.cs new file mode 100644 index 000000000..1480a75af --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/EntityDescriptor.cs @@ -0,0 +1,29 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Internal.Saml.Metadata.Models; + +/// +/// Represents a SAML entity descriptor that describes a SAML entity (IdP or SP). +/// +internal record EntityDescriptor +{ + /// + /// Gets or sets the entity ID (typically the IdP issuer URI). + /// This uniquely identifies the SAML entity. + /// + internal required string EntityId { get; set; } + + /// + /// Gets or sets the IdP SSO descriptor. + /// Contains the Identity Provider's SSO configuration and capabilities. + /// + internal IdpSsoDescriptor? IdpSsoDescriptor { get; set; } + + /// + /// Gets or sets the validity period end time (optional). + /// If set, indicates when this metadata expires. + /// + internal DateTime? ValidUntil { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/IdpSsoDescriptor.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/IdpSsoDescriptor.cs new file mode 100644 index 000000000..1554b9892 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/IdpSsoDescriptor.cs @@ -0,0 +1,50 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Collections.ObjectModel; + +namespace Duende.IdentityServer.Internal.Saml.Metadata.Models; + +/// +/// Describes a SAML Identity Provider's SSO capabilities. +/// This element contains all the information needed for a Service Provider +/// to interact with the Identity Provider. +/// +internal record IdpSsoDescriptor +{ + /// + /// Gets or sets the protocol support enumeration. + /// Typically "urn:oasis:names:tc:SAML:2.0:protocol". + /// Indicates which SAML protocols this IdP supports. + /// + internal required string ProtocolSupportEnumeration { get; set; } + + /// + /// Gets or sets whether the IdP requires authentication requests to be signed. + /// + internal bool WantAuthnRequestsSigned { get; set; } + + /// + /// Gets or sets the signing certificates. + /// Contains the public keys used to verify signatures from this IdP. + /// + internal Collection KeyDescriptors { get; init; } = []; + + /// + /// Gets or sets the supported NameID formats. + /// Indicates which name identifier formats this IdP can provide. + /// + internal Collection NameIdFormats { get; init; } = []; + + /// + /// Gets or sets the SingleSignOnService endpoints. + /// Defines where and how Service Providers can initiate SSO. + /// + internal Collection SingleSignOnServices { get; init; } = []; + + /// + /// Gets or sets the SingleLogoutService endpoints. + /// Defines where and how Service Providers can send logout requests. + /// + internal Collection SingleLogoutServices { get; init; } = []; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/KeyDescriptor.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/KeyDescriptor.cs new file mode 100644 index 000000000..32a2164ce --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/KeyDescriptor.cs @@ -0,0 +1,23 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Internal.Saml.Metadata.Models; + +/// +/// Describes a cryptographic key used by a SAML entity. +/// Contains certificate information for signature verification or encryption. +/// +internal record KeyDescriptor +{ + /// + /// Gets or sets the key usage (signing, encryption, or null for both). + /// When null, the key can be used for both signing and encryption. + /// + internal KeyUse? Use { get; set; } + + /// + /// Gets or sets the X.509 certificate in base64 encoding (without BEGIN/END markers). + /// This is the public key used to verify signatures or encrypt data. + /// + internal required string X509Certificate { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleLogoutService.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleLogoutService.cs new file mode 100644 index 000000000..54a5e6692 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleLogoutService.cs @@ -0,0 +1,26 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + + +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Internal.Saml.Metadata.Models; + +/// +/// Describes a SAML SingleLogoutService endpoint. +/// Specifies where and how a Service Provider can send logout requests. +/// +internal record SingleLogoutService +{ + /// + /// Gets or sets the binding (HTTP-Redirect, HTTP-POST, etc.). + /// Indicates the protocol binding to use for this endpoint. + /// + internal required SamlBinding Binding { get; set; } + + /// + /// Gets or sets the location URI. + /// The endpoint URI where logout requests should be sent. + /// + internal required Uri Location { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleSignOnService.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleSignOnService.cs new file mode 100644 index 000000000..fa8d4c463 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/Models/SingleSignOnService.cs @@ -0,0 +1,26 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + + +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Internal.Saml.Metadata.Models; + +/// +/// Describes a SAML SingleSignOnService endpoint. +/// Specifies where and how a Service Provider can send authentication requests. +/// +internal record SingleSignOnService +{ + /// + /// Gets or sets the binding (HTTP-Redirect, HTTP-POST, etc.). + /// Indicates the protocol binding to use for this endpoint. + /// + internal required SamlBinding Binding { get; set; } + + /// + /// Gets or sets the location URI. + /// The endpoint URI where authentication requests should be sent. + /// + internal required Uri Location { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/Metadata/SamlMetaDataEndpoint.cs b/identity-server/src/IdentityServer/Internal/Saml/Metadata/SamlMetaDataEndpoint.cs new file mode 100644 index 000000000..2eb747acb --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/Metadata/SamlMetaDataEndpoint.cs @@ -0,0 +1,124 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Xml.Linq; +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Endpoints.Results; +using Duende.IdentityServer.Hosting; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.Metadata.Models; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Duende.IdentityServer.Internal.Saml.Metadata; + +internal class SamlMetaDataEndpoint( + TimeProvider timeProvider, + IOptions samlOptions, + IIssuerNameService issuerNameService, + IServerUrls urls, + ISamlSigningService samlSigningService) : IEndpointHandler +{ + public async Task ProcessAsync(HttpContext context) + { + using var activity = Tracing.BasicActivitySource.StartActivity("SamlMetaDataEndpoint"); + + if (!HttpMethods.IsGet(context.Request.Method)) + { + return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed); + } + + var options = samlOptions.Value; + var issuerUri = await issuerNameService.GetCurrentAsync(context.RequestAborted); + var baseUrl = urls.BaseUrl; + + var certificateBase64 = await samlSigningService.GetSigningCertificateBase64Async(context.RequestAborted); + + var singleSignOnService = BuildServiceUrl(baseUrl, options.UserInteraction.Route, options.UserInteraction.SignInPath); + var singleLogoutService = BuildServiceUrl(baseUrl, options.UserInteraction.Route, options.UserInteraction.SingleLogoutPath); + + var descriptor = new EntityDescriptor + { + EntityId = issuerUri, + ValidUntil = options.MetadataValidityDuration != null + ? timeProvider.GetUtcNow().Add(options.MetadataValidityDuration.Value).UtcDateTime + : null, + IdpSsoDescriptor = new IdpSsoDescriptor + { + ProtocolSupportEnumeration = SamlConstants.Namespaces.Protocol, + WantAuthnRequestsSigned = options.WantAuthnRequestsSigned, + KeyDescriptors = + [ + new KeyDescriptor + { + Use = KeyUse.Signing, + X509Certificate = certificateBase64 + } + ], + NameIdFormats = options.SupportedNameIdFormats, + SingleSignOnServices = + [ + new SingleSignOnService + { + Binding = SamlBinding.HttpPost, + Location = singleSignOnService + }, + new SingleSignOnService + { + Binding = SamlBinding.HttpRedirect, + Location = singleSignOnService + } + ], + SingleLogoutServices = + [ + new SingleLogoutService + { + Binding = SamlBinding.HttpPost, + Location = singleLogoutService + }, + new SingleLogoutService + { + Binding = SamlBinding.HttpRedirect, + Location = singleLogoutService + } + ] + } + }; + + return new SamlMetadataResult(descriptor); + } + + private static Uri BuildServiceUrl(string baseUrl, string route, string path) + { + var builder = new UriBuilder(baseUrl); + + // Preserve existing base path and append new segments + var segments = new[] { builder.Path, route, path } + .Select(s => s.Trim('/')) + .Where(s => !string.IsNullOrWhiteSpace(s)); + + var combinedPath = string.Join('/', segments); + + // UriBuilder.Path automatically adds leading slash + builder.Path = string.IsNullOrEmpty(combinedPath) ? "/" : combinedPath; + + return builder.Uri; + } +} + +/// +/// Endpoint result that writes SAML metadata XML to the response. +/// +internal class SamlMetadataResult(EntityDescriptor descriptor) : IEndpointResult +{ + public async Task ExecuteAsync(HttpContext context) + { + context.Response.StatusCode = 200; + context.Response.ContentType = SamlConstants.ContentTypes.Metadata; + var descriptorXml = EntityDescriptorSerializer.SerializeToXml(descriptor); + await descriptorXml.SaveAsync(context.Response.Body, SaveOptions.DisableFormatting, context.RequestAborted); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/NopSamlLogoutNotificationService.cs b/identity-server/src/IdentityServer/Internal/Saml/NopSamlLogoutNotificationService.cs new file mode 100644 index 000000000..97a2fb983 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/NopSamlLogoutNotificationService.cs @@ -0,0 +1,15 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; + +namespace Duende.IdentityServer.Internal.Saml; + +internal class NopSamlLogoutNotificationService : ISamlLogoutNotificationService +{ + public Task> GetSamlFrontChannelLogoutsAsync(LogoutNotificationContext context, Ct ct) => + Task.FromResult(Enumerable.Empty()); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SamlClaimService.cs b/identity-server/src/IdentityServer/Internal/Saml/SamlClaimService.cs new file mode 100644 index 000000000..1fcd05b71 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SamlClaimService.cs @@ -0,0 +1,129 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Security.Claims; +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Duende.IdentityServer.Internal.Saml; + +internal class SamlClaimsService( + IProfileService profileService, + ILogger logger, + IOptions options, + ISamlClaimsMapper? customMapper = null) +{ + private async Task> GetClaimsAsync(ClaimsPrincipal user, SamlServiceProvider serviceProvider, Ct ct) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(serviceProvider); + + var requestedClaimTypes = user.Claims.Select(c => c.Type).Distinct(); + + // Use IdentityServer's IProfileService to get claims + var context = new ProfileDataRequestContext + { + Subject = user, + Client = new Client + { + ClientId = serviceProvider.EntityId + }, + RequestedClaimTypes = requestedClaimTypes, + Caller = "SAML" + }; + + await profileService.GetProfileDataAsync(context, ct); + + var claims = context.IssuedClaims; + + logger.RetrievedClaimsFromProfileService(LogLevel.Debug, claims.Count); + + return claims; + } + + internal async Task> GetMappedAttributesAsync( + ClaimsPrincipal user, + SamlServiceProvider serviceProvider, + Ct ct) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(serviceProvider); + + var claims = await GetClaimsAsync(user, serviceProvider, ct); + + if (customMapper != null) + { + logger.UsingCustomClaimMapper(LogLevel.Debug, serviceProvider.EntityId); + var claimsMappingContext = new SamlClaimsMappingContext { UserClaims = claims, ServiceProvider = serviceProvider }; + return await customMapper.MapClaimsAsync(claimsMappingContext); + } + + return MapClaimsToAttributes(claims, serviceProvider); + } + + private List MapClaimsToAttributes( + IEnumerable claims, + SamlServiceProvider serviceProvider) + { + var samlOptions = options.Value; + var attributes = new List(); + var claimsList = claims.ToList(); + + foreach (var claim in claimsList) + { + // Determine attribute name: SP mapping > Global mapping > null (exclude) + var attributeName = GetAttributeName(claim.Type, serviceProvider, samlOptions); + + // Skip claims that aren't mapped + if (attributeName == null) + { + continue; + } + + // Check if attribute already exists (for multi-valued attributes) + var existingAttr = attributes.FirstOrDefault(a => a.Name == attributeName); + if (existingAttr != null) + { + existingAttr.Values.Add(claim.Value); + } + else + { + attributes.Add(new SamlAttribute + { + Name = attributeName, + NameFormat = samlOptions.DefaultAttributeNameFormat, + FriendlyName = attributeName, + Values = [claim.Value] + }); + } + } + + logger.MappedClaimsToAttributes(LogLevel.Debug, claimsList.Count, attributes.Count, serviceProvider.EntityId); + + return attributes; + } + + private static string? GetAttributeName( + string claimType, + SamlServiceProvider serviceProvider, + SamlOptions options) + { + if (serviceProvider.ClaimMappings.TryGetValue(claimType, out var spMapping)) + { + return spMapping; + } + + if (options.DefaultClaimMappings.TryGetValue(claimType, out var globalMapping)) + { + return globalMapping; + } + + return null; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SamlConstants.cs b/identity-server/src/IdentityServer/Internal/Saml/SamlConstants.cs new file mode 100644 index 000000000..f34fd2d0f --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SamlConstants.cs @@ -0,0 +1,117 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Internal.Saml; + +internal static class SamlConstants +{ + internal class Urls + { + public const string SamlRoute = "/saml"; + public const string Metadata = "/metadata"; + public const string SignIn = "/signin"; + public const string SigninCallback = "/signin_callback"; + public const string IdpInitiated = "/idp-initiated"; + public const string Signout = "/signout"; + public const string SingleLogout = "/logout"; + public const string SingleLogoutCallback = "/logout_callback"; + } + + internal class RequestProperties + { + public const string SAMLRequest = "SAMLRequest"; + public const string SAMLResponse = "SAMLResponse"; + public const string RelayState = "RelayState"; + public const string Signature = "Signature"; + public const string SigAlg = "SigAlg"; + } + + internal class ContentTypes + { + /// + /// https://www.iana.org/assignments/media-types/application/samlmetadata+xml + /// + public const string Metadata = "application/samlmetadata+xml"; + } + internal static class Namespaces + { + public const string Assertion = "urn:oasis:names:tc:SAML:2.0:assertion"; + public const string Protocol = "urn:oasis:names:tc:SAML:2.0:protocol"; + public const string Metadata = "urn:oasis:names:tc:SAML:2.0:metadata"; + public const string XmlSignature = "http://www.w3.org/2000/09/xmldsig#"; + public const string XmlEncryption = "http://www.w3.org/2001/04/xmlenc#"; + } + + internal static class NameIdentifierFormats + { + public const string EmailAddress = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"; + public const string Persistent = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; + public const string Transient = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"; + public const string Unspecified = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"; + } + + internal static class Bindings + { + public const string HttpRedirect = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"; + public const string HttpPost = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"; + } + + internal static class MetadataElements + { + public const string EntityDescriptor = "EntityDescriptor"; + public const string IdpSsoDescriptor = "IDPSSODescriptor"; + public const string KeyDescriptor = "KeyDescriptor"; + public const string KeyInfo = "KeyInfo"; + public const string X509Data = "X509Data"; + public const string X509Certificate = "X509Certificate"; + public const string NameIdFormat = "NameIDFormat"; + public const string SingleSignOnService = "SingleSignOnService"; + public const string SingleLogoutService = "SingleLogoutService"; + } + + internal static class AuthenticationRequestAttributes + { + public const string RootElementName = "AuthnRequest"; + } + + internal static class MetadataAttributes + { + public const string EntityId = "entityID"; + public const string ValidUntil = "validUntil"; + public const string ProtocolSupportEnumeration = "protocolSupportEnumeration"; + public const string WantAuthnRequestsSigned = "WantAuthnRequestsSigned"; + public const string Use = "use"; + public const string Binding = "Binding"; + public const string Location = "Location"; + + /// + /// Converts a KeyUse enum value to its string representation. + /// + internal static string ToString(KeyUse keyUse) => keyUse switch + { + KeyUse.Signing => "signing", + KeyUse.Encryption => "encryption", + _ => throw new ArgumentOutOfRangeException(nameof(keyUse), keyUse, "Unknown key use") + }; + } + + public static class AttributeNameFormats + { + /// + /// Attribute name is interpreted as a URI reference (most common for OID format) + /// + public const string Uri = "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"; + } + + public static class ClaimTypes + { + public const string AuthnContextClassRef = "saml:acr"; + } + + internal static class LogoutReasons + { + public const string User = "urn:oasis:names:tc:SAML:2.0:logout:user"; + public const string Admin = "urn:oasis:names:tc:SAML:2.0:logout:admin"; + public const string GlobalTimeout = "urn:oasis:names:tc:SAML:2.0:logout:global-timeout"; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SamlResponseBuilder.cs b/identity-server/src/IdentityServer/Internal/Saml/SamlResponseBuilder.cs new file mode 100644 index 000000000..f2a1d2c18 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SamlResponseBuilder.cs @@ -0,0 +1,192 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Security.Claims; +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleSignin; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Services; +using Microsoft.Extensions.Options; + +namespace Duende.IdentityServer.Internal.Saml; + +internal class SamlResponseBuilder( + IServerUrls serverUrls, + IIssuerNameService issuerNameService, + TimeProvider timeProvider, + IOptions samlOptions, + SamlClaimsService samlClaimsService, + SamlNameIdGenerator nameIdGenerator + ) +{ + internal SamlErrorResponse BuildErrorResponse(SamlServiceProvider serviceProvider, SamlSigninRequest request, + SamlError error) + { + // Use the ACS URL from the request if present and valid, otherwise fall back to SP config + var acsUrl = request.AuthNRequest.AssertionConsumerServiceUrl + ?? (request.AuthNRequest.AssertionConsumerServiceIndex != null + ? serviceProvider.AssertionConsumerServiceUrls.ElementAtOrDefault(request.AuthNRequest + .AssertionConsumerServiceIndex.Value) + : null) + ?? serviceProvider.AssertionConsumerServiceUrls.First(); + + return new SamlErrorResponse + { + ServiceProvider = serviceProvider, + Binding = serviceProvider.AssertionConsumerServiceBinding, + StatusCode = error.StatusCode, + SubStatusCode = error.SubStatusCode, + Message = error.Message, + AssertionConsumerServiceUrl = acsUrl, + Issuer = serverUrls.Origin, // Todo: not sure if this is a valid issuer + InResponseTo = request.AuthNRequest.Id, + RelayState = request.RelayState + }; + } + + private static Conditions CreateConditions( + SamlServiceProvider samlServiceProvider, + DateTime issueInstant, + TimeSpan defaultRequestMaxAge, + TimeSpan defaultAllowedClockSkew) + { + var lifetime = samlServiceProvider.RequestMaxAge ?? defaultRequestMaxAge; + var clockSkew = samlServiceProvider.ClockSkew ?? defaultAllowedClockSkew; + + return new Conditions + { + NotBefore = issueInstant.Subtract(clockSkew), + NotOnOrAfter = issueInstant.Add(lifetime), + AudienceRestrictions = [samlServiceProvider.EntityId] + }; + } + + private static AuthnStatement CreateAuthnStatement(ClaimsPrincipal user, DateTime issueInstant, string sessionIndex) + { + // Determine AuthnContext based on request and user claims + var authnContextClassRef = GetAuthnContextClassRef(user); + + return new AuthnStatement + { + AuthnInstant = issueInstant, + SessionIndex = sessionIndex, + AuthnContext = new AuthnContext { AuthnContextClassRef = authnContextClassRef } + }; + } + + private static string GetAuthnContextClassRef(ClaimsPrincipal user) + { + var contextClaim = user.FindFirst(SamlConstants.ClaimTypes.AuthnContextClassRef); + if (contextClaim == null || string.IsNullOrWhiteSpace(contextClaim.Value)) + { + return "urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified"; + } + + return contextClaim.Value.Trim(); + } + + private static string GetEmailNameId(ClaimsPrincipal user) + { + // Try to get email claim + var email = user.FindFirst("email")?.Value + ?? user.FindFirst(ClaimTypes.Email)?.Value; + + return !string.IsNullOrEmpty(email) ? email : user.GetSubjectId(); + } + + private static Subject CreateSubject( + SamlAuthenticationState samlAuthenticationState, + NameIdentifier nameId, + SamlServiceProvider serviceProvider, + AuthNRequest? request, + TimeSpan defaultRequestMaxAge, + DateTime issueInstant) + { + var lifetime = serviceProvider.RequestMaxAge ?? defaultRequestMaxAge; + var notOnOrAfter = issueInstant.Add(lifetime); + + return new Subject + { + NameId = nameId, + SubjectConfirmations = + [ + new() + { + Method = "urn:oasis:names:tc:SAML:2.0:cm:bearer", + Data = new SubjectConfirmationData + { + NotOnOrAfter = notOnOrAfter, + Recipient = samlAuthenticationState.AssertionConsumerServiceUrl, + InResponseTo = request?.Id // Null for IdP-initiated + } + } + ] + }; + } + + internal async Task BuildSuccessResponseAsync( + ClaimsPrincipal user, + SamlServiceProvider samlServiceProvider, + SamlAuthenticationState samlAuthenticationState, + string sessionIndex, + Ct ct) + { + var now = timeProvider.GetUtcNow().DateTime; + var options = samlOptions.Value; + var nameId = nameIdGenerator.GenerateNameIdentifier(user, samlServiceProvider, samlAuthenticationState.Request); + var attributes = await samlClaimsService.GetMappedAttributesAsync(user, samlServiceProvider, ct); + + var acsUrl = GetAcsUrl(samlAuthenticationState.Request, samlServiceProvider); + + var issuer = await issuerNameService.GetCurrentAsync(ct); + + return new SamlResponse + { + ServiceProvider = samlServiceProvider, + InResponseTo = samlAuthenticationState.Request?.Id, + Destination = acsUrl, + IssueInstant = now, + Issuer = issuer, + Status = new Status + { + StatusCode = SamlStatusCodes.Success, + NestedStatusCode = samlAuthenticationState.Request?.RequestedAuthnContext != null && !samlAuthenticationState.RequestedAuthnContextRequirementsWereMet ? SamlStatusCodes.NoAuthnContext : null, + }, + Assertion = new Assertion + { + IssueInstant = now, + Issuer = issuer, + Subject = CreateSubject(samlAuthenticationState, nameId, samlServiceProvider, + samlAuthenticationState.Request, options.DefaultRequestMaxAge, + now), + Conditions = CreateConditions( + samlServiceProvider, now, + options.DefaultRequestMaxAge, + options.DefaultClockSkew), + AuthnStatements = [CreateAuthnStatement(user, now, sessionIndex)], + AttributeStatements = [new AttributeStatement { Attributes = attributes.ToList() }] + }, + RelayState = samlAuthenticationState.RelayState + }; + } + + private static Uri GetAcsUrl(AuthNRequest? request, SamlServiceProvider samlServiceProvider) + { + if (request?.AssertionConsumerServiceUrl != null) + { + return request.AssertionConsumerServiceUrl; + } + + if (request?.AssertionConsumerServiceIndex != null) + { + return samlServiceProvider.AssertionConsumerServiceUrls.ElementAt(request.AssertionConsumerServiceIndex.Value); + } + return samlServiceProvider.AssertionConsumerServiceUrls.FirstOrDefault() + ?? throw new InvalidOperationException("No ACS Url defined for service provider " + samlServiceProvider.EntityId); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Log.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Log.cs new file mode 100644 index 000000000..7d48b1e25 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Log.cs @@ -0,0 +1,268 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout; + +internal static class SingleLogoutLogParameters +{ + public const string RequestId = "requestId"; + public const string Issuer = "issuer"; + public const string SessionIndex = "sessionIndex"; + public const string Message = "message"; + public const string SpName = "spName"; + public const string StatusCode = "statusCode"; + public const string NotOnOrAfter = "notOnOrAfter"; + public const string ExpectedSessionIndex = "expectedSessionIndex"; + public const string ReceivedSessionIndex = "receivedSessionIndex"; + public const string EntityId = "entityId"; + public const string Version = "version"; + public const string IssueInstant = "issueInstant"; + public const string Destination = "destination"; + public const string ExpectedDestination = "expectedDestination"; + public const string Count = "count"; +} + +internal static partial class Log +{ + [LoggerMessage( + EventName = nameof(ParsedLogoutRequest), + Message = $"Parsed LogoutRequest. ID: {{{SingleLogoutLogParameters.RequestId}}}, Issuer: {{{SingleLogoutLogParameters.Issuer}}}, SessionIndex: {{{SingleLogoutLogParameters.SessionIndex}}}" + )] + internal static partial void ParsedLogoutRequest(this ILogger logger, LogLevel logLevel, string requestId, string issuer, string sessionIndex); + + [LoggerMessage( + EventName = nameof(FailedToParseLogoutRequest), + Level = LogLevel.Error, + Message = $"Failed to parse LogoutRequest: {{{SingleLogoutLogParameters.Message}}}")] + internal static partial void FailedToParseLogoutRequest(this ILogger logger, Exception exception, string message); + + [LoggerMessage( + EventName = nameof(UnexpectedErrorParsingLogoutRequest), + Level = LogLevel.Error, + Message = "Unexpected error parsing LogoutRequest")] + internal static partial void UnexpectedErrorParsingLogoutRequest(this ILogger logger, Exception exception); + + [LoggerMessage( + EventName = nameof(ReceivedLogoutRequest), + Message = $"Received SAML LogoutRequest from {{{SingleLogoutLogParameters.Issuer}}}. RequestId: {{{SingleLogoutLogParameters.RequestId}}}, SessionIndex: {{{SingleLogoutLogParameters.SessionIndex}}}")] + internal static partial void ReceivedLogoutRequest(this ILogger logger, LogLevel logLevel, string issuer, string requestId, string sessionIndex); + + [LoggerMessage( + EventName = nameof(SuccessfullyProcessedLogoutRequest), + Message = $"Logout request {{{SingleLogoutLogParameters.RequestId}}} with session index {{{SingleLogoutLogParameters.SessionIndex}}} processed successfully")] + internal static partial void SuccessfullyProcessedLogoutRequest(this ILogger logger, LogLevel logLevel, string requestId, string sessionIndex); + + [LoggerMessage( + EventName = nameof(SamlLogoutValidationError), + Message = $"SAML logout validation error: {{{SingleLogoutLogParameters.Message}}}")] + internal static partial void SamlLogoutValidationError(this ILogger logger, LogLevel logLevel, string message); + + [LoggerMessage( + EventName = nameof(SamlLogoutProtocolError), + Message = $"SAML logout protocol error: {{{SingleLogoutLogParameters.StatusCode}}} - {{{SingleLogoutLogParameters.Message}}}")] + internal static partial void SamlLogoutProtocolError(this ILogger logger, LogLevel logLevel, string statusCode, string message); + + [LoggerMessage( + EventName = nameof(SamlLogoutRequestFromUnknownOrDisabledServiceProvider), + Message = $"LogoutRequest from unknown or disabled SP: {{{SingleLogoutLogParameters.Issuer}}}")] + internal static partial void SamlLogoutRequestFromUnknownOrDisabledServiceProvider(this ILogger logger, LogLevel logLevel, string issuer); + + [LoggerMessage( + EventName = nameof(ProcessingSamlLogoutRequest), + Message = $"Processing LogoutRequest {{{SingleLogoutLogParameters.RequestId}}} from SP: {{{SingleLogoutLogParameters.SpName}}} ({{{SingleLogoutLogParameters.Issuer}}})")] + internal static partial void ProcessingSamlLogoutRequest(this ILogger logger, LogLevel logLevel, string requestId, string spName, string issuer); + + [LoggerMessage( + EventName = nameof(SamlLogoutRequestReceivedButNoActiveUserSession), + Message = $"LogoutRequest {{{SingleLogoutLogParameters.RequestId}}} received from {{{SingleLogoutLogParameters.Issuer}}} but no active user session found")] + internal static partial void SamlLogoutRequestReceivedButNoActiveUserSession(this ILogger logger, LogLevel logLevel, string requestId, string issuer); + + [LoggerMessage( + EventName = nameof(SamlLogoutRequestReceivedWithWrongSessionIndex), + Message = $"SessionIndex mismatch. Request: {{{SingleLogoutLogParameters.RequestId}}}, SessionIndex: {{{SingleLogoutLogParameters.SessionIndex}}}")] + internal static partial void SamlLogoutRequestReceivedWithWrongSessionIndex(this ILogger logger, LogLevel logLevel, string requestId, string sessionIndex); + + [LoggerMessage( + EventName = nameof(SamlLogoutRedirectToLogoutPage), + Message = $"Redirecting SAML logout to host logout page {{{SingleLogoutLogParameters.Issuer}}}")] + internal static partial void SamlLogoutRedirectToLogoutPage(this ILogger logger, LogLevel logLevel, string issuer); + + [LoggerMessage( + EventName = nameof(SamlLogoutNoCertificatesForSignatureValidation), + Message = $"SP {{{SingleLogoutLogParameters.EntityId}}} has no signing certificates configured. LogoutRequest requires signature authentication")] + internal static partial void SamlLogoutNoCertificatesForSignatureValidation(this ILogger logger, LogLevel logLevel, string entityId); + + [LoggerMessage( + EventName = nameof(SamlLogoutRequestExpired), + Message = $"LogoutRequest {{{SingleLogoutLogParameters.RequestId}}} expired. NotOnOrAfter: {{{SingleLogoutLogParameters.NotOnOrAfter}}}")] + internal static partial void SamlLogoutRequestExpired(this ILogger logger, LogLevel logLevel, string requestId, DateTime notOnOrAfter); + + [LoggerMessage( + EventName = nameof(SamlLogoutSignatureValidationFailed), + Message = $"LogoutRequest signature validation failed for SP {{{SingleLogoutLogParameters.EntityId}}}: {{{SingleLogoutLogParameters.Message}}}")] + internal static partial void SamlLogoutSignatureValidationFailed(this ILogger logger, LogLevel logLevel, string entityId, string message); + + [LoggerMessage( + EventName = nameof(SamlLogoutSignatureValidationSucceeded), + Message = "LogoutRequest signature validated successfully")] + internal static partial void SamlLogoutSignatureValidationSucceeded(this ILogger logger, LogLevel logLevel); + + [LoggerMessage( + EventName = nameof(SamlLogoutNoSessionFoundForServiceProvider), + Message = $"No session with session index {{{SingleLogoutLogParameters.SessionIndex}}} found for SP {{{SingleLogoutLogParameters.Issuer}}}")] + internal static partial void SamlLogoutNoSessionFoundForServiceProvider(this ILogger logger, LogLevel logLevel, string sessionIndex, string issuer); + + [LoggerMessage( + EventName = nameof(SamlLogoutSessionIndexMisMatch), + Message = $"SessionIndex mismatch. Expected: {{{SingleLogoutLogParameters.ExpectedSessionIndex}}}, Received: {{{SingleLogoutLogParameters.ReceivedSessionIndex}}}")] + internal static partial void SamlLogoutSessionIndexMisMatch(this ILogger logger, LogLevel logLevel, string expectedSessionIndex, string receivedSessionIndex); + + [LoggerMessage( + EventName = nameof(SamlLogoutNoSingleLogoutServiceUrl), + Message = $"SP {{{SingleLogoutLogParameters.EntityId}}} has no SingleLogoutServiceUrl configured. Cannot send LogoutResponse")] + internal static partial void SamlLogoutNoSingleLogoutServiceUrl(this ILogger logger, LogLevel logLevel, string entityId); + + [LoggerMessage( + EventName = nameof(SamlLogoutUnsupportedVersion), + Message = $"LogoutRequest has unsupported SAML version: {{{SingleLogoutLogParameters.Version}}}. Only 2.0 is supported")] + internal static partial void SamlLogoutUnsupportedVersion(this ILogger logger, LogLevel logLevel, string version); + + [LoggerMessage( + EventName = nameof(SamlLogoutRequestIssueInstantInFuture), + Message = $"LogoutRequest {{{SingleLogoutLogParameters.RequestId}}} has IssueInstant in the future: {{{SingleLogoutLogParameters.IssueInstant}}}")] + internal static partial void SamlLogoutRequestIssueInstantInFuture(this ILogger logger, LogLevel logLevel, string requestId, DateTime issueInstant); + + [LoggerMessage( + EventName = nameof(SamlLogoutRequestIssueInstantTooOld), + Message = $"LogoutRequest {{{SingleLogoutLogParameters.RequestId}}} has IssueInstant too old (expired): {{{SingleLogoutLogParameters.IssueInstant}}}")] + internal static partial void SamlLogoutRequestIssueInstantTooOld(this ILogger logger, LogLevel logLevel, string requestId, DateTime issueInstant); + + [LoggerMessage( + EventName = nameof(SamlLogoutRequestInvalidDestination), + Message = $"LogoutRequest {{{SingleLogoutLogParameters.RequestId}}} has invalid Destination. Received: {{{SingleLogoutLogParameters.Destination}}}, Expected: {{{SingleLogoutLogParameters.ExpectedDestination}}}")] + internal static partial void SamlLogoutRequestInvalidDestination(this ILogger logger, LogLevel logLevel, string requestId, Uri destination, string expectedDestination); + + [LoggerMessage( + EventName = nameof(ProcessingSamlLogoutCallback), + Message = "Processing SAML logout callback")] + internal static partial void ProcessingSamlLogoutCallback(this ILogger logger, LogLevel logLevel); + + [LoggerMessage( + EventName = nameof(MissingLogoutId), + Message = "Missing logoutId parameter in callback request")] + internal static partial void MissingLogoutId(this ILogger logger, LogLevel logLevel); + + [LoggerMessage( + EventName = nameof(InvalidLogoutId), + Message = "Invalid logoutId in callback request: {logoutId}")] + internal static partial void InvalidLogoutId(this ILogger logger, LogLevel logLevel, string logoutId); + + [LoggerMessage( + EventName = nameof(NotSamlInitiatedLogout), + Message = "Logout callback was not for a SAML logout")] + internal static partial void NotSamlInitiatedLogout(this ILogger logger, LogLevel logLevel); + + [LoggerMessage( + EventName = nameof(ServiceProviderNotFound), + Message = $"Service Provider not found for EntityId: {{{SingleLogoutLogParameters.EntityId}}}")] + internal static partial void ServiceProviderNotFound(this ILogger logger, LogLevel logLevel, string entityId); + + [LoggerMessage( + EventName = nameof(ReturningLogoutResponseToSp), + Message = $"Returning LogoutResponse to Service Provider: {{{SingleLogoutLogParameters.EntityId}}}")] + internal static partial void ReturningLogoutResponseToSp(this ILogger logger, LogLevel logLevel, string entityId); + + [LoggerMessage( + EventName = nameof(NoSamlServiceProvidersToNotifyForLogout), + Message = "No SAML Service Providers to notify for logout")] + internal static partial void NoSamlServiceProvidersToNotifyForLogout(this ILogger logger, LogLevel logLevel); + + [LoggerMessage( + EventName = nameof(SkippingLogoutUrlGenerationForUnknownOrDisabledServiceProvider), + Message = $"Skipping SAML logout for disabled or unknown SP: {{{SingleLogoutLogParameters.EntityId}}}")] + internal static partial void SkippingLogoutUrlGenerationForUnknownOrDisabledServiceProvider(this ILogger logger, LogLevel logLevel, string entityId); + + [LoggerMessage( + EventName = nameof(SkippingLogoutUrlGenerationForServiceProviderWithNoSingleLogout), + Message = $"Skipping SAML logout for SP without any SingleLogoutServiceUrl: {{{SingleLogoutLogParameters.EntityId}}}")] + internal static partial void SkippingLogoutUrlGenerationForServiceProviderWithNoSingleLogout(this ILogger logger, LogLevel logLevel, string entityId); + + [LoggerMessage( + EventName = nameof(NoSessionDataFoundForLogoutUrlGenerationForServiceProvider), + Message = $"No session data found for SP: {{{SingleLogoutLogParameters.EntityId}}}")] + internal static partial void NoSessionDataFoundForLogoutUrlGenerationForServiceProvider(this ILogger logger, LogLevel logLevel, string entityId); + + [LoggerMessage( + EventName = nameof(FailedToGenerateLogoutUrlForServiceProvider), + Level = LogLevel.Error, + Message = $"Failed to build SAML logout URL for SP: {{{SingleLogoutLogParameters.EntityId}}}")] + internal static partial void FailedToGenerateLogoutUrlForServiceProvider(this ILogger logger, Exception ex, string entityId); + + [LoggerMessage( + EventName = nameof(GeneratedSamlFrontChannelLogoutUrls), + Message = $"Generated {{{SingleLogoutLogParameters.Count}}} SAML front-channel logout URLs")] + internal static partial void GeneratedSamlFrontChannelLogoutUrls(this ILogger logger, LogLevel logLevel, int count); + + [LoggerMessage( + EventName = nameof(NoSamlFrontChannelLogoutUrlsGenerated), + Message = "No SAML front-channel logout URLs generated")] + internal static partial void NoSamlFrontChannelLogoutUrlsGenerated(this ILogger logger, LogLevel logLevel); + + [LoggerMessage( + EventName = nameof(NoLogoutMessageFound), + Message = $"No logout message found for logoutId: {{logoutId}}")] + internal static partial void NoLogoutMessageFound(this ILogger logger, LogLevel logLevel, string logoutId); + + [LoggerMessage( + EventName = nameof(LogoutMessageMissingSamlEntityId), + Message = "Logout message does not contain SAML SP entity ID")] + internal static partial void LogoutMessageMissingSamlEntityId(this ILogger logger, LogLevel logLevel); + + [LoggerMessage( + EventName = nameof(BuildingLogoutResponseForSp), + Message = $"Building SAML logout response for SP: {{{SingleLogoutLogParameters.EntityId}}}")] + internal static partial void BuildingLogoutResponseForSp(this ILogger logger, LogLevel logLevel, string entityId); + + [LoggerMessage( + EventName = nameof(ServiceProviderDisabled), + Message = $"Service Provider is disabled: {{{SingleLogoutLogParameters.EntityId}}}")] + internal static partial void ServiceProviderDisabled(this ILogger logger, LogLevel logLevel, string entityId); + + [LoggerMessage( + EventName = nameof(LogoutMessageMissingRequestId), + Message = "Logout message does not contain SAML logout request ID")] + internal static partial void LogoutMessageMissingRequestId(this ILogger logger, LogLevel logLevel); + + [LoggerMessage( + EventName = nameof(SuccessfullyBuiltLogoutResponse), + Message = $"Successfully built SAML logout response for SP: {{{SingleLogoutLogParameters.EntityId}}}, InResponseTo: {{{SingleLogoutLogParameters.RequestId}}}")] + internal static partial void SuccessfullyBuiltLogoutResponse(this ILogger logger, LogLevel logLevel, string entityId, string requestId); + + [LoggerMessage( + EventName = nameof(InvalidHttpMethodForLogoutCallback), + Message = "Invalid HTTP method for SAML logout callback endpoint")] + internal static partial void InvalidHttpMethodForLogoutCallback(this ILogger logger, LogLevel logLevel); + + [LoggerMessage( + EventName = nameof(ProcessingSamlLogoutCallbackRequest), + Message = "Processing SAML logout callback request")] + internal static partial void ProcessingSamlLogoutCallbackRequest(this ILogger logger, LogLevel logLevel); + + [LoggerMessage( + EventName = nameof(MissingLogoutIdParameter), + Message = "Missing logoutId parameter in SAML logout callback")] + internal static partial void MissingLogoutIdParameter(this ILogger logger, LogLevel logLevel); + + [LoggerMessage( + EventName = nameof(ErrorProcessingLogoutCallback), + Message = $"Error processing SAML logout callback: {{{SingleLogoutLogParameters.Message}}}")] + internal static partial void ErrorProcessingLogoutCallback(this ILogger logger, LogLevel logLevel, string message); + + [LoggerMessage( + EventName = nameof(SuccessfullyProcessedLogoutCallback), + Message = "Successfully processed SAML logout callback")] + internal static partial void SuccessfullyProcessedLogoutCallback(this ILogger logger, LogLevel logLevel); +} + diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutRequestParser.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutRequestParser.cs new file mode 100644 index 000000000..75b7de7cd --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutRequestParser.cs @@ -0,0 +1,130 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Globalization; +using System.Xml; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleLogout.Models; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Saml.Models; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout; + +/// +/// Parses SAML LogoutRequest messages. +/// +internal class LogoutRequestParser(ILogger logger) : SamlProtocolMessageParser +{ + /// + /// Parses a LogoutRequest from XML. + /// + internal LogoutRequest Parse(XDocument doc) + { + try + { + var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol); + var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion); + + var root = doc.Root; + if (root?.Name != protocolNs + LogoutRequest.ElementNames.RootElement) + { + throw new FormatException($"Root element is not LogoutRequest. Found: {root?.Name}"); + } + + var request = new LogoutRequest + { + Id = GetRequiredAttribute(root, LogoutRequest.AttributeNames.Id), + Version = GetRequiredAttribute(root, LogoutRequest.AttributeNames.Version), + IssueInstant = ParseDateTime(root, LogoutRequest.AttributeNames.IssueInstant), + Destination = GetOptionalAttribute(root, LogoutRequest.AttributeNames.Destination) is { } dest ? new Uri(dest) : null, + Issuer = ParseIssuerValue(root, assertionNs, "LogoutRequest"), + NameId = ParseNameIdAsIdentifier(root, assertionNs), + SessionIndex = ParseSessionIndex(root, protocolNs), + Reason = ParseReason(GetOptionalAttribute(root, LogoutRequest.AttributeNames.Reason)), + }; + + var notOnOrAfterAttr = root.Attribute(LogoutRequest.AttributeNames.NotOnOrAfter)?.Value; + if (!string.IsNullOrEmpty(notOnOrAfterAttr)) + { + request.NotOnOrAfter = DateTime.Parse(notOnOrAfterAttr, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal); + } + + logger.ParsedLogoutRequest(LogLevel.Debug, request.Id, request.Issuer, request.SessionIndex); + + return request; + } + catch (XmlException ex) + { + logger.FailedToParseLogoutRequest(ex, ex.Message); + throw; + } + catch (Exception ex) + { + logger.UnexpectedErrorParsingLogoutRequest(ex); + throw; + } + } + + + + private static NameIdentifier ParseNameIdAsIdentifier(XElement root, XNamespace assertionNs) + { + var nameIdElement = root.Element(assertionNs + LogoutRequest.ElementNames.NameID); + if (nameIdElement == null) + { + throw new InvalidOperationException("NameID element is required in LogoutRequest"); + } + + var nameId = nameIdElement.Value?.Trim(); + if (string.IsNullOrEmpty(nameId)) + { + throw new InvalidOperationException("NameID element cannot be empty"); + } + + var format = nameIdElement.Attribute(NameIdPolicy.AttributeNames.Format)?.Value; + var nameQualifier = nameIdElement.Attribute("NameQualifier")?.Value; + var spNameQualifierAttr = nameIdElement.Attribute(NameIdPolicy.AttributeNames.SPNameQualifier); + + string? spNameQualifier = null; + if (spNameQualifierAttr != null && !string.IsNullOrWhiteSpace(spNameQualifierAttr.Value)) + { + spNameQualifier = spNameQualifierAttr.Value; + } + + return new NameIdentifier + { + Value = nameId, + Format = format, + NameQualifier = nameQualifier, + SPNameQualifier = spNameQualifier + }; + } + + private static string ParseSessionIndex(XElement root, XNamespace protocolNs) + { + var sessionIndexElement = root.Element(protocolNs + LogoutRequest.ElementNames.SessionIndex); + if (sessionIndexElement == null) + { + throw new InvalidOperationException("SessionIndex element is required in LogoutRequest"); + } + + var sessionIndex = sessionIndexElement.Value.Trim(); + if (string.IsNullOrEmpty(sessionIndex)) + { + throw new InvalidOperationException("SessionIndex element cannot be empty"); + } + + return sessionIndex; + } + + private static LogoutReason? ParseReason(string? reasonUrn) => reasonUrn switch + { + SamlConstants.LogoutReasons.User => LogoutReason.User, + SamlConstants.LogoutReasons.Admin => LogoutReason.Admin, + SamlConstants.LogoutReasons.GlobalTimeout => LogoutReason.GlobalTimeout, + _ => null + }; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutResponseBuilder.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutResponseBuilder.cs new file mode 100644 index 000000000..3a3fa7349 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/LogoutResponseBuilder.cs @@ -0,0 +1,68 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Internal.Saml.SingleLogout.Models; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Services; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout; + +internal class LogoutResponseBuilder( + IIssuerNameService issuerNameService, + TimeProvider timeProvider) +{ + internal async Task BuildSuccessResponseAsync( + string logoutRequestId, + SamlServiceProvider serviceProvider, + string? relayState, + Ct ct) + { + var issuer = await issuerNameService.GetCurrentAsync(ct); + var destination = serviceProvider.SingleLogoutServiceUrl ?? throw new InvalidOperationException("No SingleLogout service url configured"); + + return new LogoutResponse + { + Id = SamlIds.NewResponseId(), + IssueInstant = timeProvider.GetUtcNow().UtcDateTime, + Destination = destination.Location, + Issuer = issuer, + InResponseTo = logoutRequestId, + Status = new Status + { + StatusCode = SamlStatusCodes.Success + }, + ServiceProvider = serviceProvider, + RelayState = relayState + }; + } + + internal async Task BuildErrorResponseAsync( + SamlLogoutRequest request, + SamlServiceProvider serviceProvider, + SamlError error, + Ct ct) + { + var issuer = await issuerNameService.GetCurrentAsync(ct); + var destination = serviceProvider.SingleLogoutServiceUrl ?? throw new InvalidOperationException("No SingleLogout service url configured"); + + return new LogoutResponse + { + Id = SamlIds.NewResponseId(), + IssueInstant = timeProvider.GetUtcNow().UtcDateTime, + Destination = destination.Location, + Issuer = issuer, + InResponseTo = request.LogoutRequest.Id, + Status = new Status + { + StatusCode = error.StatusCode, + StatusMessage = error.Message, + NestedStatusCode = error.SubStatusCode + }, + ServiceProvider = serviceProvider, + RelayState = request.RelayState + }; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutRequest.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutRequest.cs new file mode 100644 index 000000000..b3b571cc2 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutRequest.cs @@ -0,0 +1,101 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Saml.Models; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout.Models; + +/// +/// Represents a SAML 2.0 LogoutRequest message. +/// +internal record LogoutRequest : ISamlRequest +{ + public static string MessageName => "SAML logout request"; + + /// + /// Gets or sets the unique identifier for this request. + /// + public required string Id { get; set; } + + /// + /// Gets or sets the SAML version. Must be "2.0". + /// + public string Version { get; set; } = SamlVersions.V2; + + /// + /// Gets or sets the time instant of issue in UTC. + /// + public required DateTime IssueInstant { get; set; } + + /// + /// Gets or sets the URI of the destination endpoint where this request is sent. + /// + public Uri? Destination { get; set; } + + /// + /// Gets or sets the entity identifier of the issuer (sender) of this request. + /// + public required string Issuer { get; set; } + + /// + /// Gets or sets the NameID identifying the principal that is being logged out. + /// + public required NameIdentifier NameId { get; set; } + + /// + /// Gets or sets the SessionIndex identifying the session to be terminated. + /// + public required string SessionIndex { get; set; } + + /// + /// Gets or sets the reason for the logout (optional). + /// + public LogoutReason? Reason { get; set; } + + /// + /// Gets or sets the NotOnOrAfter time limit for the logout operation. + /// + public DateTime? NotOnOrAfter { get; set; } + + internal static class AttributeNames + { + public const string Id = "ID"; + public const string Version = "Version"; + public const string IssueInstant = "IssueInstant"; + public const string Reason = "Reason"; + public const string NotOnOrAfter = "NotOnOrAfter"; + public const string Destination = "Destination"; + } + + internal static class ElementNames + { + public const string RootElement = "LogoutRequest"; + public const string Issuer = "Issuer"; + public const string NameID = "NameID"; + public const string SessionIndex = "SessionIndex"; + } +} + +/// +/// Represents the reason for logout in a LogoutRequest. +/// +internal enum LogoutReason +{ + /// + /// User initiated the logout. + /// + User, + + /// + /// Administrator initiated the logout. + /// + Admin, + + /// + /// Logout due to global timeout. + /// + GlobalTimeout +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutResponse.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutResponse.cs new file mode 100644 index 000000000..acc1d8aaa --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/LogoutResponse.cs @@ -0,0 +1,132 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Globalization; +using System.Text; +using System.Xml.Linq; +using Duende.IdentityServer.Endpoints.Results; +using Duende.IdentityServer.Hosting; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Microsoft.AspNetCore.Http; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout.Models; + +/// +/// Represents a SAML 2.0 LogoutResponse message. +/// +internal class LogoutResponse : EndpointResult +{ + /// + /// Gets or sets the unique identifier for this response. + /// + public required string Id { get; set; } + + /// + /// Gets or sets the SAML version. Must be "2.0". + /// + public string Version { get; set; } = SamlVersions.V2; + + /// + /// Gets or sets the time instant of issue in UTC. + /// + public required DateTime IssueInstant { get; set; } + + /// + /// Gets or sets the URI of the destination endpoint where this response is sent. + /// + public required Uri Destination { get; set; } + + /// + /// Gets or sets the entity identifier of the issuer (sender) of this response. + /// + public required string Issuer { get; set; } + + /// + /// Gets or sets the ID of the LogoutRequest to which this is a response. + /// + public required string InResponseTo { get; set; } + + /// + /// Gets or sets the status of the logout operation. + /// + public required Status Status { get; set; } + + /// + /// Gets or sets the service provider configuration for this response. + /// + public required SamlServiceProvider ServiceProvider { get; set; } + + /// + /// Gets or sets the optional RelayState parameter to return to the SP. + /// + public string? RelayState { get; set; } + + internal static class ElementNames + { + public const string RootElement = "LogoutResponse"; + } + + internal class ResponseWriter(ISamlResultSerializer serializer, SamlProtocolMessageSigner samlProtocolMessageSigner) : IHttpResponseWriter + { + public async Task WriteHttpResponse(LogoutResponse result, HttpContext httpContext) + { + var responseXml = serializer.Serialize(result); + + var signedResponseXml = await samlProtocolMessageSigner.SignProtocolMessage(responseXml, result.ServiceProvider, httpContext.RequestAborted); + + var encodedResponse = Convert.ToBase64String(Encoding.UTF8.GetBytes(signedResponseXml)); + + var html = HttpResponseBindings.GenerateAutoPostForm(SamlConstants.RequestProperties.SAMLResponse, encodedResponse, result.Destination, result.RelayState); + + httpContext.Response.ContentType = "text/html"; + httpContext.Response.Headers.CacheControl = "no-cache, no-store"; + httpContext.Response.Headers.Pragma = "no-cache"; + + await httpContext.Response.WriteAsync(html); + } + } + + internal class Serializer : ISamlResultSerializer + { + public XElement Serialize(LogoutResponse toSerialize) + { + var issueInstant = toSerialize.IssueInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); + + var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol); + + // Build Status element + var statusCodeElement = new XElement(protocolNs + "StatusCode", + new XAttribute("Value", toSerialize.Status.StatusCode.ToString())); + + if (!string.IsNullOrEmpty(toSerialize.Status.NestedStatusCode)) + { + statusCodeElement.Add( + new XElement(protocolNs + "StatusCode", + new XAttribute("Value", toSerialize.Status.NestedStatusCode))); + } + + var statusElement = new XElement(protocolNs + "Status", statusCodeElement); + + if (!string.IsNullOrEmpty(toSerialize.Status.StatusMessage)) + { + statusElement.Add(new XElement(protocolNs + "StatusMessage", toSerialize.Status.StatusMessage)); + } + + // Build LogoutResponse element + var responseElement = new XElement(protocolNs + ElementNames.RootElement, + new XAttribute("ID", toSerialize.Id), + new XAttribute("Version", toSerialize.Version), + new XAttribute("IssueInstant", issueInstant), + new XAttribute("Destination", toSerialize.Destination), + new XAttribute("InResponseTo", toSerialize.InResponseTo), + new XElement(XNamespace.Get(SamlConstants.Namespaces.Assertion) + "Issuer", toSerialize.Issuer), + statusElement); + + return responseElement; + } + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/SamlLogoutRequest.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/SamlLogoutRequest.cs new file mode 100644 index 000000000..432d73ec1 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/Models/SamlLogoutRequest.cs @@ -0,0 +1,52 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout.Models; + +/// +/// Represents a SAML logout request with binding information. +/// +internal record SamlLogoutRequest : SamlRequestBase +{ + public static async ValueTask BindAsync(HttpContext context) + { + var extractor = context.RequestServices.GetRequiredService(); + return await extractor.ExtractAsync(context); + } + + public LogoutRequest LogoutRequest => Request; +} + +internal class SamlLogoutRequestExtractor : SamlRequestExtractor +{ + private readonly LogoutRequestParser _parser; + + public SamlLogoutRequestExtractor(LogoutRequestParser parser) => _parser = parser; + + protected override LogoutRequest ParseRequest(XDocument xmlDocument) => _parser.Parse(xmlDocument); + + protected override SamlLogoutRequest CreateResult( + LogoutRequest parsedRequest, + XDocument requestXml, + SamlBinding binding, + string? relayState, + string? signature = null, + string? signatureAlgorithm = null, + string? encodedSamlRequest = null) => new SamlLogoutRequest + { + Request = parsedRequest, + RequestXml = requestXml, + Binding = binding, + RelayState = relayState, + Signature = signature, + SignatureAlgorithm = signatureAlgorithm, + EncodedSamlRequest = encodedSamlRequest + }; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlFrontChannelLogoutRequestBuilder.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlFrontChannelLogoutRequestBuilder.cs new file mode 100644 index 000000000..f42774efc --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlFrontChannelLogoutRequestBuilder.cs @@ -0,0 +1,142 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Globalization; +using System.IO.Compression; +using System.Text; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleLogout.Models; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; +using LogoutRequest = Duende.IdentityServer.Internal.Saml.SingleLogout.Models.LogoutRequest; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout; + +internal class SamlFrontChannelLogoutRequestBuilder( + TimeProvider timeProvider, + SamlProtocolMessageSigner samlProtocolMessageSigner) +{ + internal async Task BuildLogoutRequestAsync( + SamlServiceProvider serviceProvider, + string nameId, + string? nameIdFormat, + string sessionIndex, + string issuer, + Ct ct) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + + if (serviceProvider.SingleLogoutServiceUrl == null) + { + throw new InvalidOperationException( + $"Service Provider '{serviceProvider.EntityId}' has no SingleLogoutServiceUrl configured"); + } + + var logoutRequest = new LogoutRequest + { + Id = SamlIds.NewRequestId(), + IssueInstant = timeProvider.GetUtcNow().UtcDateTime, + Destination = serviceProvider.SingleLogoutServiceUrl.Location, + Issuer = issuer, + NameId = new NameIdentifier { Value = nameId, Format = nameIdFormat }, + SessionIndex = sessionIndex + }; + + var requestXml = SerializeLogoutRequest(logoutRequest); + + return serviceProvider.SingleLogoutServiceUrl.Binding switch + { + SamlBinding.HttpRedirect => await BuildRedirectLogoutRequest(serviceProvider.SingleLogoutServiceUrl.Location, requestXml, ct), + SamlBinding.HttpPost => await BuildHttpPostLogoutRequest(serviceProvider, requestXml, ct), + _ => throw new InvalidOperationException( + $"Binding '{serviceProvider.SingleLogoutServiceUrl.Binding}' is not supported") + }; + } + + private static XElement SerializeLogoutRequest(LogoutRequest logoutRequest) + { + var issueInstant = + logoutRequest.IssueInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); + var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol); + var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion); + + var requestElement = new XElement(protocolNs + LogoutRequest.ElementNames.RootElement, + new XAttribute("ID", logoutRequest.Id), + new XAttribute("Version", logoutRequest.Version), + new XAttribute("IssueInstant", issueInstant), + new XAttribute("Destination", logoutRequest.Destination!), + new XElement(assertionNs + LogoutRequest.ElementNames.Issuer, logoutRequest.Issuer)); + + var nameIdElement = new XElement(assertionNs + LogoutRequest.ElementNames.NameID, logoutRequest.NameId.Value); + if (!string.IsNullOrEmpty(logoutRequest.NameId.Format)) + { + nameIdElement.Add(new XAttribute("Format", logoutRequest.NameId.Format)); + } + + requestElement.Add(nameIdElement); + + requestElement.Add(new XElement(protocolNs + LogoutRequest.ElementNames.SessionIndex, + logoutRequest.SessionIndex)); + + if (logoutRequest.Reason.HasValue) + { + var reasonValue = logoutRequest.Reason.Value switch + { + LogoutReason.User => "urn:oasis:names:tc:SAML:2.0:logout:user", + LogoutReason.Admin => "urn:oasis:names:tc:SAML:2.0:logout:admin", + LogoutReason.GlobalTimeout => "urn:oasis:names:tc:SAML:2.0:logout:global-timeout", + _ => null + }; + + if (reasonValue != null) + { + requestElement.Add(new XAttribute("Reason", reasonValue)); + } + } + + if (logoutRequest.NotOnOrAfter.HasValue) + { + var notOnOrAfter = + logoutRequest.NotOnOrAfter.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); + requestElement.Add(new XAttribute("NotOnOrAfter", notOnOrAfter)); + } + + return requestElement; + } + + private async Task BuildRedirectLogoutRequest(Uri singleLogoutServiceUri, XElement requestXml, Ct ct) + { + var encodedRequest = DeflateAndEncode(requestXml.ToString()); + + var queryString = $"?SAMLRequest={Uri.EscapeDataString(encodedRequest)}"; + + var signedQueryString = await samlProtocolMessageSigner.SignQueryString(queryString, ct); + + return new SamlHttpRedirectFrontChannelLogout(singleLogoutServiceUri, signedQueryString); + } + + private static string DeflateAndEncode(string xml) + { + var bytes = Encoding.UTF8.GetBytes(xml); + + using var output = new MemoryStream(); + using (var deflateStream = new DeflateStream(output, CompressionLevel.Optimal)) + { + deflateStream.Write(bytes, 0, bytes.Length); + } + + return Convert.ToBase64String(output.ToArray()); + } + + private async Task BuildHttpPostLogoutRequest(SamlServiceProvider serviceProvider, XElement requestXml, Ct ct) + { + var signedRequestXml = await samlProtocolMessageSigner.SignProtocolMessage(requestXml, serviceProvider, ct); + + var encodedXml = Convert.ToBase64String(Encoding.UTF8.GetBytes(signedRequestXml)); + + return new SamlHttpPostFrontChannelLogout(serviceProvider.SingleLogoutServiceUrl!.Location, encodedXml, null); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpPostFrontChannelLogout.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpPostFrontChannelLogout.cs new file mode 100644 index 000000000..2f231d358 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpPostFrontChannelLogout.cs @@ -0,0 +1,19 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout; + +internal class SamlHttpPostFrontChannelLogout(Uri frontChannelLogoutUri, string logoutRequest, string? relayState) : ISamlFrontChannelLogout +{ + public SamlBinding SamlBinding => SamlBinding.HttpPost; + + public Uri Destination => frontChannelLogoutUri; + + public string EncodedContent => logoutRequest; + + public string? RelayState => relayState; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpRedirectFrontChannelLogout.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpRedirectFrontChannelLogout.cs new file mode 100644 index 000000000..06d36bee0 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlHttpRedirectFrontChannelLogout.cs @@ -0,0 +1,19 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout; + +internal class SamlHttpRedirectFrontChannelLogout(Uri frontChannelLogoutUri, string encodedContent) : ISamlFrontChannelLogout +{ + public SamlBinding SamlBinding => SamlBinding.HttpRedirect; + + public Uri Destination => frontChannelLogoutUri; + + public string EncodedContent => encodedContent; + + public string? RelayState { get; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutCallbackProcessor.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutCallbackProcessor.cs new file mode 100644 index 000000000..419107550 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutCallbackProcessor.cs @@ -0,0 +1,79 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleLogout.Models; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout; + +/// +/// Processes SAML Single Logout callback requests after user logout completes. +/// +internal class SamlLogoutCallbackProcessor( + IMessageStore logoutMessageStore, + ISamlServiceProviderStore serviceProviderStore, + LogoutResponseBuilder logoutResponseBuilder, + ILogger logger) +{ + internal async Task> ProcessAsync(string logoutId, Ct ct = default) + { + var logoutMessage = await logoutMessageStore.ReadAsync(logoutId, ct); + if (logoutMessage?.Data == null) + { + logger.NoLogoutMessageFound(LogLevel.Warning, logoutId); + return new SamlLogoutCallbackError("No logout message found"); + } + + var data = logoutMessage.Data; + if (data.SamlServiceProviderEntityId == null) + { + logger.LogoutMessageMissingSamlEntityId(LogLevel.Warning); + return new SamlLogoutCallbackError("Logout message does not contain SAML SP entity ID"); + } + + logger.BuildingLogoutResponseForSp(LogLevel.Debug, data.SamlServiceProviderEntityId); + + var sp = await serviceProviderStore.FindByEntityIdAsync(data.SamlServiceProviderEntityId, ct); + if (sp == null) + { + logger.ServiceProviderNotFound(LogLevel.Error, data.SamlServiceProviderEntityId); + return new SamlLogoutCallbackError($"Service Provider not found: {data.SamlServiceProviderEntityId}"); + } + + if (!sp.Enabled) + { + logger.ServiceProviderDisabled(LogLevel.Error, sp.EntityId); + return new SamlLogoutCallbackError($"Service Provider is disabled: {sp.EntityId}"); + } + + if (sp.SingleLogoutServiceUrl == null) + { + logger.SamlLogoutNoSingleLogoutServiceUrl(LogLevel.Error, sp.EntityId); + return new SamlLogoutCallbackError($"Service Provider has no SingleLogoutServiceUrl configured: {sp.EntityId}"); + } + + if (string.IsNullOrWhiteSpace(data.SamlLogoutRequestId)) + { + logger.LogoutMessageMissingRequestId(LogLevel.Error); + return new SamlLogoutCallbackError("Logout message does not contain SAML logout request ID"); + } + + var response = await logoutResponseBuilder.BuildSuccessResponseAsync( + data.SamlLogoutRequestId, + sp, + data.SamlRelayState, + ct); + + logger.SuccessfullyBuiltLogoutResponse(LogLevel.Information, data.SamlServiceProviderEntityId, data.SamlLogoutRequestId); + + return response; + } +} + +/// +/// Represents an error during SAML logout callback processing. +/// +internal record SamlLogoutCallbackError(string Message); diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutNotificationService.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutNotificationService.cs new file mode 100644 index 000000000..2e48c1ded --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutNotificationService.cs @@ -0,0 +1,78 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout; + +internal class SamlLogoutNotificationService( + IIssuerNameService issuerNameService, + ISamlServiceProviderStore serviceProviderStore, + SamlFrontChannelLogoutRequestBuilder frontChannelLogoutRequestBuilder, + ILogger logger) : ISamlLogoutNotificationService +{ + public async Task> GetSamlFrontChannelLogoutsAsync(LogoutNotificationContext context, Ct ct) + { + using var activity = Tracing.ServiceActivitySource.StartActivity("LogoutNotificationService.GetSamlFrontChannelLogoutUrls"); + + var logoutUrls = new List(); + + if (!context.SamlSessions.Any()) + { + logger.NoSamlServiceProvidersToNotifyForLogout(LogLevel.Debug); + return logoutUrls; + } + + var issuer = await issuerNameService.GetCurrentAsync(ct); + + foreach (var sessionData in context.SamlSessions ?? []) + { + var sp = await serviceProviderStore.FindByEntityIdAsync(sessionData.EntityId, ct); + if (sp?.Enabled != true) + { + logger.SkippingLogoutUrlGenerationForUnknownOrDisabledServiceProvider(LogLevel.Debug, sessionData.EntityId); + continue; + } + + if (sp.SingleLogoutServiceUrl == null) + { + logger.SkippingLogoutUrlGenerationForServiceProviderWithNoSingleLogout(LogLevel.Debug, sessionData.EntityId); + continue; + } + + try + { + var logoutUrl = await frontChannelLogoutRequestBuilder.BuildLogoutRequestAsync( + sp, + sessionData.NameId, + sessionData.NameIdFormat, + sessionData.SessionIndex, + issuer, + ct); + + logoutUrls.Add(logoutUrl); + } +#pragma warning disable CA1031 // Do not catch general exception types: one failure should not stop the whole process + catch (Exception ex) +#pragma warning restore CA1031 + { + logger.FailedToGenerateLogoutUrlForServiceProvider(ex, sessionData.EntityId); + } + } + + if (logoutUrls.Count > 0) + { + logger.GeneratedSamlFrontChannelLogoutUrls(LogLevel.Debug, logoutUrls.Count); + } + else + { + logger.NoSamlFrontChannelLogoutUrlsGenerated(LogLevel.Debug); + } + + return logoutUrls; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutRequestProcessor.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutRequestProcessor.cs new file mode 100644 index 000000000..7e585d937 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutRequestProcessor.cs @@ -0,0 +1,175 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Security.Claims; +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleLogout.Models; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using LogoutRequest = Duende.IdentityServer.Internal.Saml.SingleLogout.Models.LogoutRequest; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout; + +internal class SamlLogoutRequestProcessor : SamlRequestProcessorBase +{ + private readonly IUserSession _userSession; + private readonly LogoutResponseBuilder _logoutResponseBuilder; + private readonly IMessageStore _logoutMessageStore; + private readonly TimeProvider _timeProvider; + private readonly SamlUrlBuilder _urlBuilder; + + public SamlLogoutRequestProcessor( + ISamlServiceProviderStore serviceProviderStore, + IUserSession userSession, + SamlRequestSignatureValidator signatureValidator, + LogoutResponseBuilder logoutResponseBuilder, + IServerUrls serverUrls, + IOptions options, + IMessageStore logoutMessageStore, + TimeProvider timeProvider, + SamlUrlBuilder urlBuilder, + SamlRequestValidator requestValidator, + ILogger logger) + : base( + serviceProviderStore, + options, + requestValidator, + signatureValidator, + logger, + serverUrls.GetAbsoluteUrl(options.Value.UserInteraction.Route + options.Value.UserInteraction.SingleLogoutPath)) + { + _userSession = userSession; + _logoutResponseBuilder = logoutResponseBuilder; + _logoutMessageStore = logoutMessageStore; + _timeProvider = timeProvider; + _urlBuilder = urlBuilder; + } + + protected override async Task>> ProcessValidatedRequestAsync( + SamlServiceProvider sp, + SamlLogoutRequest request, + Ct ct = default) + { + var logoutRequest = request.LogoutRequest; + + if (sp.SingleLogoutServiceUrl == null) + { + Logger.SamlLogoutNoSingleLogoutServiceUrl(LogLevel.Error, sp.EntityId); + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = $"Service Provider '{sp.EntityId}' has no SingleLogoutServiceUrl configured" + }; + } + + Logger.ProcessingSamlLogoutRequest(LogLevel.Debug, logoutRequest.Id, sp.DisplayName, logoutRequest.Issuer); + + var user = await _userSession.GetUserAsync(ct); + if (user == null) + { + Logger.SamlLogoutRequestReceivedButNoActiveUserSession(LogLevel.Debug, logoutRequest.Id, logoutRequest.Issuer); + var noUserAuthenticatedResponse = await _logoutResponseBuilder.BuildSuccessResponseAsync(logoutRequest.Id, sp, request.RelayState, ct); + // there is no user to log out, return success + return SamlLogoutSuccess.CreateResponse(noUserAuthenticatedResponse); + } + + var sessionMatch = await ValidateSessionIndexAsync(sp, logoutRequest.SessionIndex, ct); + if (!sessionMatch) + { + Logger.SamlLogoutRequestReceivedWithWrongSessionIndex(LogLevel.Warning, logoutRequest.Id, logoutRequest.SessionIndex); + var noSessionIndexResponse = await _logoutResponseBuilder.BuildSuccessResponseAsync(logoutRequest.Id, sp, request.RelayState, ct); + // there is no session to terminate, return success + return SamlLogoutSuccess.CreateResponse(noSessionIndexResponse); + } + + Logger.SamlLogoutRedirectToLogoutPage(LogLevel.Information, logoutRequest.Issuer); + + var logoutId = await StoreLogoutMessageAsync(user, sp, request, ct); + var logoutUri = _urlBuilder.SamlLogoutUri(logoutId); + + return SamlLogoutSuccess.CreateRedirect(logoutUri); + } + + protected override bool RequireSignature(SamlServiceProvider sp) => + // SAML 2.0 spec requires LogoutRequest to be signed + true; + + protected override SamlRequestError? ValidateMessageSpecific(SamlServiceProvider sp, SamlLogoutRequest request) + { + var logoutRequest = request.LogoutRequest; + + // Validate NotOnOrAfter if present + if (logoutRequest.NotOnOrAfter.HasValue) + { + var now = _timeProvider.GetUtcNow(); + var clockSkew = sp.ClockSkew ?? SamlOptions.DefaultClockSkew; + + if (now.Subtract(clockSkew) > logoutRequest.NotOnOrAfter.Value) + { + Logger.SamlLogoutRequestExpired(LogLevel.Warning, logoutRequest.Id, logoutRequest.NotOnOrAfter.Value); + return new SamlRequestError + { + Type = SamlRequestErrorType.Protocol, + ProtocolError = new SamlProtocolError(sp, request, new SamlError + { + StatusCode = SamlStatusCodes.Requester, + Message = "Logout request expired (NotOnOrAfter is in the past)" + }) + }; + } + } + + return null; + } + + private async Task ValidateSessionIndexAsync(SamlServiceProvider sp, string sessionIndex, Ct ct) + { + var samlSessions = await _userSession.GetSamlSessionListAsync(ct); + + var spSession = samlSessions.FirstOrDefault(s => s.EntityId == sp.EntityId); + + if (spSession == null) + { + Logger.SamlLogoutNoSessionFoundForServiceProvider(LogLevel.Debug, sessionIndex, sp.EntityId); + return false; + } + + if (spSession.SessionIndex != sessionIndex) + { + Logger.SamlLogoutSessionIndexMisMatch(LogLevel.Debug, spSession.SessionIndex, sessionIndex); + return false; + } + + return true; + } + + private async Task StoreLogoutMessageAsync(ClaimsPrincipal user, SamlServiceProvider serviceProvider, SamlLogoutRequest logoutRequest, Ct ct) + { + var samlSessions = await _userSession.GetSamlSessionListAsync(ct); + + var oidcClientIds = await _userSession.GetClientListAsync(ct); + + var logoutMessage = new LogoutMessage + { + SubjectId = user.GetSubjectId(), + SessionId = await _userSession.GetSessionIdAsync(ct), + ClientIds = oidcClientIds, + SamlServiceProviderEntityId = serviceProvider.EntityId, + SamlSessions = samlSessions, + SamlLogoutRequestId = logoutRequest.LogoutRequest.Id, + SamlRelayState = logoutRequest.RelayState, + PostLogoutRedirectUri = _urlBuilder.SamlLogoutCallBackUri().ToString() + }; + + var msg = new Message(logoutMessage, _timeProvider.GetUtcNow().UtcDateTime); + + return await _logoutMessageStore.WriteAsync(msg, ct); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutResults.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutResults.cs new file mode 100644 index 000000000..87910cc79 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlLogoutResults.cs @@ -0,0 +1,20 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Hosting; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleLogout.Models; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout; + +internal record SamlLogoutSuccess +{ + private SamlLogoutSuccess(IEndpointResult result) => Result = result; + + public IEndpointResult Result { get; private set; } + + public static SamlLogoutSuccess CreateResponse(LogoutResponse logoutResponse) => + new(logoutResponse); + + public static SamlLogoutSuccess CreateRedirect(Uri redirectUri) => new(new RedirectResult(redirectUri)); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutCallbackEndpoint.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutCallbackEndpoint.cs new file mode 100644 index 000000000..c2aa62ec3 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutCallbackEndpoint.cs @@ -0,0 +1,50 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Net; +using Duende.IdentityServer.Endpoints.Results; +using Duende.IdentityServer.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout; + +/// +/// Endpoint for completing SAML Single Logout and sending the LogoutResponse back to the initiating Service Provider. +/// This is called after the user completes logout and all front-channel logout notifications have been sent. +/// +internal class SamlSingleLogoutCallbackEndpoint( + SamlLogoutCallbackProcessor processor, + ILogger logger) : IEndpointHandler +{ + public async Task ProcessAsync(HttpContext context) + { + using var activity = Tracing.BasicActivitySource.StartActivity("SamlSingleLogoutCallbackEndpoint"); + + if (!HttpMethods.IsGet(context.Request.Method)) + { + return new StatusCodeResult(HttpStatusCode.MethodNotAllowed); + } + + logger.ProcessingSamlLogoutCallbackRequest(LogLevel.Debug); + + var logoutId = context.Request.Query["logoutId"].ToString(); + if (string.IsNullOrWhiteSpace(logoutId)) + { + logger.MissingLogoutIdParameter(LogLevel.Warning); + return new StatusCodeResult(HttpStatusCode.BadRequest); + } + + var result = await processor.ProcessAsync(logoutId, context.RequestAborted); + + if (!result.Success) + { + logger.ErrorProcessingLogoutCallback(LogLevel.Error, result.Error.Message); + return new StatusCodeResult(HttpStatusCode.BadRequest); + } + + logger.SuccessfullyProcessedLogoutCallback(LogLevel.Information); + return result.Value; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutEndpoint.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutEndpoint.cs new file mode 100644 index 000000000..9aba25db7 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleLogout/SamlSingleLogoutEndpoint.cs @@ -0,0 +1,77 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Endpoints.Results; +using Duende.IdentityServer.Hosting; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleLogout.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.SingleLogout; + +internal class SamlSingleLogoutEndpoint( + SamlLogoutRequestExtractor extractor, + SamlLogoutRequestProcessor processor, + LogoutResponseBuilder responseBuilder, + ILogger logger) : IEndpointHandler +{ + public async Task ProcessAsync(HttpContext context) + { + using var activity = Tracing.BasicActivitySource.StartActivity("SamlSingleLogoutEndpoint"); + + if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsPost(context.Request.Method)) + { + return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed); + } + + // Extract the SAML logout request from query string (GET/Redirect) or form (POST) + var logoutRequest = await extractor.ExtractAsync(context); + + return await ProcessLogoutRequest(logoutRequest, context.RequestAborted); + } + + internal async Task ProcessLogoutRequest(SamlLogoutRequest logoutRequest, Ct ct = default) + { + logger.ReceivedLogoutRequest(LogLevel.Debug, logoutRequest.LogoutRequest.Issuer, logoutRequest.LogoutRequest.Id, logoutRequest.LogoutRequest.SessionIndex); + + var result = await processor.ProcessAsync(logoutRequest, ct); + + if (!result.Success) + { + var error = result.Error; + return error.Type switch + { + SamlRequestErrorType.Validation => HandleValidationError(error), + SamlRequestErrorType.Protocol => await HandleProtocolError(error, ct), + _ => throw new InvalidOperationException($"Unexpected error type: {error.Type}") + }; + } + + var success = result.Value; + logger.SuccessfullyProcessedLogoutRequest(LogLevel.Information, logoutRequest.LogoutRequest.Id, logoutRequest.LogoutRequest.SessionIndex); + + return success.Result; + } + + private ValidationProblemResult HandleValidationError(SamlRequestError error) + { + logger.SamlLogoutValidationError(LogLevel.Information, error.ValidationMessage!); + return new ValidationProblemResult(error.ValidationMessage!); + } + + private async Task HandleProtocolError(SamlRequestError error, Ct ct) + { + var protocolError = error.ProtocolError!; + logger.SamlLogoutProtocolError(LogLevel.Information, + protocolError.Error.StatusCode, + protocolError.Error.Message); + + return await responseBuilder.BuildErrorResponseAsync( + protocolError.Request, + protocolError.ServiceProvider, + protocolError.Error, + ct); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/AuthNRequestParser.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/AuthNRequestParser.cs new file mode 100644 index 000000000..33aefc0f9 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/AuthNRequestParser.cs @@ -0,0 +1,140 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Globalization; +using System.Xml; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Saml.Models; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal class AuthNRequestParser : SamlProtocolMessageParser +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public AuthNRequestParser(ILogger logger) => _logger = logger; + + internal AuthNRequest Parse(XDocument doc) + { + try + { + var ns = XNamespace.Get(SamlConstants.Namespaces.Protocol); + var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion); + + var root = doc.Root; + if (root?.Name != ns + SamlConstants.AuthenticationRequestAttributes.RootElementName) + { + throw new FormatException( + $"Root element is not AuthnRequest. Found: {root?.Name}"); + } + + var request = new AuthNRequest + { + Id = GetRequiredAttribute(root, AuthNRequest.AttributeNames.Id), + Version = GetRequiredAttribute(root, AuthNRequest.AttributeNames.Version), + IssueInstant = ParseDateTime(root, AuthNRequest.AttributeNames.IssueInstant), + Destination = GetOptionalAttribute(root, AuthNRequest.AttributeNames.Destination) is { } dest ? new Uri(dest) : null, + Consent = GetOptionalAttribute(root, AuthNRequest.AttributeNames.Consent), + Issuer = ParseIssuerValue(root, assertionNs, "AuthnRequest"), + ForceAuthn = ParseBooleanAttribute(root, AuthNRequest.AttributeNames.ForceAuthn, false), + IsPassive = ParseBooleanAttribute(root, AuthNRequest.AttributeNames.IsPassive, false), + AssertionConsumerServiceUrl = + GetOptionalAttribute(root, AuthNRequest.AttributeNames.AssertionConsumerServiceUrl) is { } acsUrl ? new Uri(acsUrl) : null, + AssertionConsumerServiceIndex = + ParseIntegerAttribute(root, AuthNRequest.AttributeNames.AssertionConsumerServiceIndex), + ProtocolBinding = + SamlBindingExtensions.FromUrnOrDefault(GetOptionalAttribute(root, AuthNRequest.AttributeNames.ProtocolBinding)) + }; + + // Parse optional elements + // request.Subject = ParseSubject(root, assertionNs); + request.NameIdPolicy = ParseNameIdPolicy(root, ns); + // request.Conditions = ParseConditions(root, assertionNs); + request.RequestedAuthnContext = ParseRequestedAuthnContext(root, ns); + // request.Scoping = ParseScoping(root, ns); + + _logger.ParsedAuthenticationRequest(request.Id, request.Issuer); + + return request; + } + catch (XmlException ex) + { + _logger.FailedToParseAuthNRequest(ex, ex.Message); + throw; + } + catch (Exception ex) + { + _logger.UnexpectedErrorParsingAuthNRequest(ex); + throw; + } + } + + private static NameIdPolicy? ParseNameIdPolicy(XElement root, XNamespace ns) + { + var nameIdPolicyElement = root.Element(ns + AuthNRequest.ElementNames.NameIdPolicy); + if (nameIdPolicyElement == null) + { + return null; + } + + var format = GetOptionalAttribute(nameIdPolicyElement, NameIdPolicy.AttributeNames.Format); + var spNameQualifier = GetOptionalAttribute(nameIdPolicyElement, NameIdPolicy.AttributeNames.SPNameQualifier); + + // If element exists but all attributes are null/default, still return object + // to indicate element was present (SP may want default behavior explicitly) + return new NameIdPolicy + { + Format = string.IsNullOrWhiteSpace(format) ? null : format.Trim(), + SPNameQualifier = string.IsNullOrWhiteSpace(spNameQualifier) ? null : spNameQualifier.Trim() + }; + } + + private static int? ParseIntegerAttribute(XElement element, string attributeName) + { + var value = GetOptionalAttribute(element, attributeName); + if (string.IsNullOrEmpty(value)) + { + return null; + } + + return int.Parse(value, CultureInfo.InvariantCulture); + } + + private static RequestedAuthnContext? ParseRequestedAuthnContext(XElement root, XNamespace ns) + { + var requestedAuthnContextElement = root.Element(ns + AuthNRequest.ElementNames.RequestedAuthnContext); + if (requestedAuthnContextElement == null) + { + return null; + } + + // Parse Comparison attribute (defaults to "exact" per spec) + var comparisonAttr = requestedAuthnContextElement.Attribute(RequestedAuthnContext.AttributeNames.Comparison)?.Value; + var comparison = AuthnContextComparisonExtensions.Parse(comparisonAttr); + + var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion); + var classRefs = requestedAuthnContextElement + .Elements(assertionNs + RequestedAuthnContext.ElementNames.AuthnContextClassRef) + .Select(e => e.Value?.Trim()) + .Where(v => !string.IsNullOrEmpty(v)) + .Select(v => v!) + .ToList(); + + if (classRefs.Count == 0) + { + throw new InvalidOperationException("No AuthnContextClassRef element found in requestedAuthnContext"); + } + + return new RequestedAuthnContext + { + AuthnContextClassRefs = classRefs.AsReadOnly(), + Comparison = comparison + }; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DefaultSamlSigninInteractionResponseGenerator.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DefaultSamlSigninInteractionResponseGenerator.cs new file mode 100644 index 000000000..f338ac15c --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DefaultSamlSigninInteractionResponseGenerator.cs @@ -0,0 +1,86 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal class DefaultSamlSigninInteractionResponseGenerator( + IUserSession userSession, + ILogger logger, + IHttpContextAccessor httpContextAccessor) + : ISamlSigninInteractionResponseGenerator +{ + public async Task ProcessInteractionAsync(SamlServiceProvider sp, AuthNRequest request, Ct ct = default) + { + var signedInUser = await userSession.GetUserAsync(ct); + + if (signedInUser != null) + { + if (request.IsPassive && request.ForceAuthn) + { + // Below is quite ambiguous in the spec. IsPassive means no user interaction. But ForceAuthn means we must re-authenticate. + // For now, we have no way to re-authenticate the user without user interaction. + + // From the spec: + //ForceAuthn[Optional] + //A Boolean value.If "true", the identity provider MUST authenticate the presenter directly rather than + //rely on a previous security context. If a value is not provided, the default is "false".However, if both + // ForceAuthn and IsPassive are "true", the identity provider MUST NOT freshly authenticate the + //presenter unless the constraints of IsPassive can be met. + logger.SamlInteractionPassiveAndForced(LogLevel.Debug); + return SamlInteractionResponse.CreateError(SamlStatusCodes.NoPassive, "The user is not currently logged in"); + } + + if (request.ForceAuthn) + { + logger.SamlInteractionForced(LogLevel.Debug); + + ArgumentNullException.ThrowIfNull(httpContextAccessor.HttpContext, nameof(httpContextAccessor.HttpContext)); + await httpContextAccessor.HttpContext.SignOutAsync(); + + return SamlInteractionResponse.Create(SamlInteractionResponseType.Login); + } + + logger.SamlInteractionAlreadyAuthenticated(LogLevel.Debug); + return SamlInteractionResponse.Create(SamlInteractionResponseType.AlreadyAuthenticated); + } + + if (request.IsPassive) + { + logger.SamlInteractionNoPassive(LogLevel.Debug); + return SamlInteractionResponse.CreateError(SamlStatusCodes.NoPassive, "The user is not currently logged in and passive login was requested."); + } + + // Todo: The AuthN request may contain hints on account creation 3.4.1.1 Element : AllowCreate + + + // Consent is a weird one. + // There is no way for SAML for an SP to mandate that a consent screen should be shown. + if (sp.RequireConsent && !IsConsentAcquired(request.Consent)) + { + logger.SamlInteractionConsent(LogLevel.Debug); + return SamlInteractionResponse.Create(SamlInteractionResponseType.Consent); + } + + logger.SamlInteractionLogin(LogLevel.Debug); + return SamlInteractionResponse.Create(SamlInteractionResponseType.Login); + } + + /// + /// Determines whether consent has been acquired based on the SAML consent URN value. + /// See SAML 2.0 Core spec section 8.4. + /// + private static bool IsConsentAcquired(string? consent) => consent is + "urn:oasis:names:tc:SAML:2.0:consent:obtained" or + "urn:oasis:names:tc:SAML:2.0:consent:prior" or + "urn:oasis:names:tc:SAML:2.0:consent:current-implicit" or + "urn:oasis:names:tc:SAML:2.0:consent:current-explicit"; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DistributedCacheSamlSigninStateStore.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DistributedCacheSamlSigninStateStore.cs new file mode 100644 index 000000000..456acdaeb --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/DistributedCacheSamlSigninStateStore.cs @@ -0,0 +1,54 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Text.Json; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Microsoft.Extensions.Caching.Distributed; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal class DistributedCacheSamlSigninStateStore(IDistributedCache cache) : ISamlSigninStateStore +{ + private const string KeyPrefix = "saml-signin-state:"; + private static readonly DistributedCacheEntryOptions CacheOptions = new() + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) + }; + + public async Task StoreSigninRequestStateAsync(SamlAuthenticationState state, Ct ct = default) + { + var stateId = StateId.NewId(); + var key = GetKey(stateId); + var json = JsonSerializer.Serialize(state); + + await cache.SetStringAsync(key, json, CacheOptions, ct); + + return stateId; + } + + public async Task RetrieveSigninRequestStateAsync(StateId stateId, Ct ct = default) + { + var key = GetKey(stateId); + var json = await cache.GetStringAsync(key, ct); + + if (json == null) + { + return null; + } + + await cache.RemoveAsync(key, ct); + + return JsonSerializer.Deserialize(json); + } + + public async Task UpdateSigninRequestStateAsync(StateId stateId, SamlAuthenticationState state, Ct ct = default) + { + var key = GetKey(stateId); + var json = JsonSerializer.Serialize(state); + + await cache.SetStringAsync(key, json, CacheOptions, ct); + } + + private static string GetKey(StateId stateId) => $"{KeyPrefix}{stateId.Value}"; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/ISamlSigninStateStore.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/ISamlSigninStateStore.cs new file mode 100644 index 000000000..a95a953e0 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/ISamlSigninStateStore.cs @@ -0,0 +1,14 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal interface ISamlSigninStateStore +{ + Task StoreSigninRequestStateAsync(SamlAuthenticationState request, Ct ct = default); + Task RetrieveSigninRequestStateAsync(StateId stateId, Ct ct = default); + Task UpdateSigninRequestStateAsync(StateId stateId, SamlAuthenticationState state, Ct ct = default); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Log.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Log.cs new file mode 100644 index 000000000..3ff6288f8 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Log.cs @@ -0,0 +1,48 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal static class SingleSignInLogParameters +{ + public const string Message = "Message"; + public const string RequestId = "Id"; + public const string Issuer = "Issuer"; + public const string Format = "Format"; + public const string SPNameQualifier = "SPNameQualifier"; + public const string Source = "Source"; +} + +internal static partial class Log +{ + [LoggerMessage(LogLevel.Error, + Message = $"Failed to parse AuthnRequest XML: {{{SingleSignInLogParameters.Message}}}")] + internal static partial void FailedToParseAuthNRequest(this ILogger logger, Exception ex, string message); + + [LoggerMessage(LogLevel.Error, + Message = "Unexpected error parsing AuthnRequest")] + internal static partial void UnexpectedErrorParsingAuthNRequest(this ILogger logger, Exception ex); + + [LoggerMessage(LogLevel.Debug, + Message = + $"Parsed AuthnRequest {{{SingleSignInLogParameters.RequestId}}} from {{{SingleSignInLogParameters.Issuer}}}")] + internal static partial void ParsedAuthenticationRequest(this ILogger logger, string id, string issuer); + + [LoggerMessage( + EventName = nameof(NameIdPolicyParsed), + Message = $"Parsed NameIDPolicy: Format='{{{SingleSignInLogParameters.Format}}}', SPNameQualifier='{{{SingleSignInLogParameters.SPNameQualifier}}}'")] + internal static partial void NameIdPolicyParsed(this ILogger logger, LogLevel level, string? format, string? spNameQualifier); + + [LoggerMessage( + EventName = nameof(RequestedNameIdFormatNotSupported), + Message = $"Requested NameID format '{{{SingleSignInLogParameters.Format}}}' is not supported, returning InvalidNameIDPolicy error")] + internal static partial void RequestedNameIdFormatNotSupported(this ILogger logger, LogLevel level, string format); + + [LoggerMessage( + EventName = nameof(UsingNameIdFormat), + Message = $"Using NameID format '{{{SingleSignInLogParameters.Format}}}'")] + internal static partial void UsingNameIdFormat(this ILogger logger, LogLevel level, string format); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Assertion.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Assertion.cs new file mode 100644 index 000000000..ddebc7e67 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Assertion.cs @@ -0,0 +1,63 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Saml.Models; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +internal record Assertion +{ + // ev: This can also be a UUIDV7 + + /// + /// Unique identifier for this assertion + /// Must start with a _ character and be unique + /// + /// According to SAML 2.0 Core Specification (Section 1.3.4): + ///- ID attributes must be of type xs:ID + ///- xs:ID must conform to the NCName production (Non-Colonized Name) from the XML Namespaces specification + ///- NCName cannot start with a digit, colon, or certain other characters + /// + public string Id { get; } = SamlIds.NewAssertionId(); + + /// + /// SAML version (must be "2.0") + /// + public string Version { get; } = SamlVersions.V2; + + /// + /// Time instant of issuance + /// + public required DateTime IssueInstant { get; set; } + + /// + /// Identifies the entity that issued the assertion + /// + public required string Issuer { get; set; } + + /// + /// The subject of the assertion + /// + public Subject? Subject { get; set; } + + /// + /// Conditions under which the assertion is valid + /// + public Conditions? Conditions { get; set; } + + /// + /// Authentication statements + /// + public List AuthnStatements { get; set; } = []; + + /// + /// Attribute statements + /// + public List AttributeStatements { get; set; } = []; + + /// + /// Authorization decision statements + /// + public List AuthzDecisionStatements { get; set; } = []; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AttributeStatement.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AttributeStatement.cs new file mode 100644 index 000000000..e99743bd4 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AttributeStatement.cs @@ -0,0 +1,17 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Saml.Models; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +/// +/// Represents a SAML 2.0 AttributeStatement element +/// +internal record AttributeStatement +{ + /// + /// Attributes in this statement + /// + public List Attributes { get; set; } = []; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnContext.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnContext.cs new file mode 100644 index 000000000..0fb592368 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnContext.cs @@ -0,0 +1,16 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +/// +/// Represents a SAML 2.0 AuthnContext element +/// +internal record AuthnContext +{ + /// + /// Authentication context class reference (URI) + /// + public string? AuthnContextClassRef { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnStatement.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnStatement.cs new file mode 100644 index 000000000..ed5c72847 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthnStatement.cs @@ -0,0 +1,31 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +/// +/// Represents a SAML 2.0 AuthnStatement element +/// +internal record AuthnStatement +{ + /// + /// Time at which the authentication took place + /// + public required DateTime AuthnInstant { get; set; } + + /// + /// Session index for the authenticated session + /// + public string? SessionIndex { get; set; } + + /// + /// Time instant at which the session expires + /// + public DateTime? SessionNotOnOrAfter { get; set; } + + /// + /// Authentication context + /// + public AuthnContext? AuthnContext { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthzDecisionStatement.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthzDecisionStatement.cs new file mode 100644 index 000000000..421dd3728 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/AuthzDecisionStatement.cs @@ -0,0 +1,26 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +/// +/// Represents a SAML 2.0 AuthzDecisionStatement element (Section 2.7.4) +/// +internal record AuthzDecisionStatement +{ + /// + /// URI reference identifying the resource to which access authorization is sought + /// + public required string Resource { get; set; } + + /// + /// The decision rendered by the SAML authority with respect to the specified resource + /// + public DecisionType Decision { get; set; } + + /// + /// A set of assertions that the SAML authority relied on in making the decision (optional) + /// + public Evidence? Evidence { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Conditions.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Conditions.cs new file mode 100644 index 000000000..7c0b468a3 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Conditions.cs @@ -0,0 +1,27 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Collections.ObjectModel; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +/// +/// Represents SAML 2.0 Conditions element +/// +internal record Conditions +{ + /// + /// Time instant before which the assertion is invalid + /// + public DateTime? NotBefore { get; set; } + + /// + /// Time instant at which the assertion expires + /// + public DateTime? NotOnOrAfter { get; set; } + + /// + /// Audience restrictions for the assertion + /// + public ReadOnlyCollection AudienceRestrictions { get; init; } = new List().AsReadOnly(); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/DecisionType.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/DecisionType.cs new file mode 100644 index 000000000..0cfefc5d7 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/DecisionType.cs @@ -0,0 +1,25 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +/// +/// Represents the decision rendered by the SAML authority +/// +internal enum DecisionType +{ + /// + /// The specified action is permitted + /// + Permit, + + /// + /// The specified action is denied + /// + Deny, + + /// + /// The SAML authority cannot determine whether the action is permitted or denied + /// + Indeterminate +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Evidence.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Evidence.cs new file mode 100644 index 000000000..dcdaccd29 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Evidence.cs @@ -0,0 +1,25 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +/// +/// Represents evidence supporting the authorization decision (optional) +/// +internal record Evidence +{ + /// + /// URI references to assertions + /// + public List AssertionIDRefs { get; set; } = []; + + /// + /// URI references to assertions + /// + public List AssertionURIRefs { get; set; } = []; + + /// + /// Embedded assertions + /// + public List Assertions { get; set; } = []; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/NameIdentifier.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/NameIdentifier.cs new file mode 100644 index 000000000..7f30395da --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/NameIdentifier.cs @@ -0,0 +1,32 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + + +#nullable enable +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +/// +/// Represents a SAML 2.0 NameID element +/// +internal record NameIdentifier +{ + /// + /// The name identifier value + /// + public required string Value { get; set; } + + /// + /// The format of the name identifier (URI) + /// + public string? Format { get; set; } + + /// + /// The NameQualifier attribute + /// + public string? NameQualifier { get; set; } + + /// + /// The SPNameQualifier attribute + /// + public string? SPNameQualifier { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlAuthenticationState.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlAuthenticationState.cs new file mode 100644 index 000000000..b9ab3e37d --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlAuthenticationState.cs @@ -0,0 +1,45 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Saml.Models; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +/// +/// Represents the stored context for a SAML authentication flow. +/// +internal record SamlAuthenticationState +{ + /// + /// Gets or sets the original AuthnRequest. + /// Will be null for IdP-initiated SSO flows. + /// + public AuthNRequest? Request { get; set; } + + public required string ServiceProviderEntityId { get; init; } + + /// + /// Gets or sets the RelayState parameter from the original request. + /// For IdP-initiated SSO, this typically contains the target URL at the SP. + /// + public string? RelayState { get; set; } + + /// + /// Gets or sets a value indicating whether this is an IdP-initiated SSO flow. + /// If true, there was no AuthnRequest and the response will be unsolicited. + /// + public bool IsIdpInitiated { get; set; } + + /// + /// Gets or sets the timestamp when this context was created. + /// + public DateTimeOffset CreatedUtc { get; set; } + + public required Uri AssertionConsumerServiceUrl { get; set; } + + /// + /// Gets or sets whether the RequestedAuthnContext in the request were met. + /// + public bool RequestedAuthnContextRequirementsWereMet { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlIds.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlIds.cs new file mode 100644 index 000000000..5da52c68d --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlIds.cs @@ -0,0 +1,16 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +/// +/// Generates SAML-compliant ID values. +/// SAML 2.0 IDs must conform to xs:ID (NCName), which cannot start with a digit, +/// so we prefix with underscore as required by the spec. +/// +internal static class SamlIds +{ + internal static string NewRequestId() => "_" + Guid.NewGuid().ToString("N"); + internal static string NewResponseId() => "_" + Guid.NewGuid().ToString("N"); + internal static string NewAssertionId() => "_" + Guid.NewGuid().ToString("N"); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlResponse.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlResponse.cs new file mode 100644 index 000000000..ebff6f7e9 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlResponse.cs @@ -0,0 +1,337 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Globalization; +using System.Text; +using System.Xml.Linq; +using Duende.IdentityServer.Endpoints.Results; +using Duende.IdentityServer.Hosting; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Microsoft.AspNetCore.Http; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +internal class SamlResponse : EndpointResult +{ + /// + /// Gets or sets the unique identifier for this response. + /// + public string Id { get; } = SamlIds.NewResponseId(); + + /// + /// Gets or sets the SAML version. Must be "2.0". + /// + public string Version { get; } = SamlVersions.V2; + + /// + /// Gets or sets the time instant of issue in UTC. + /// + public required DateTime IssueInstant { get; set; } + + /// + /// Gets or sets the URI of the destination endpoint where this response is sent. + /// This is the SP's Assertion Consumer Service (ACS) URL. + /// + public required Uri Destination { get; set; } + + /// + /// Gets or sets the entity identifier of the Identity Provider sending this response. + /// + public required string Issuer { get; set; } + + /// + /// Gets or sets the ID of the request to which this is a response. + /// Will be null for IdP-initiated SSO (unsolicited responses). + /// + public string? InResponseTo { get; set; } + + /// + /// Gets or sets the status of this response. + /// + public required Status Status { get; set; } + + /// + /// Gets or sets the assertion included in this response. + /// Will be null for error responses. + /// + public Assertion? Assertion { get; set; } + + /// + /// Gets or sets the relay state included in this response. + /// + public string? RelayState { get; set; } + + /// + /// Gets or sets the Service Provider where the response will be sent. + /// + public required SamlServiceProvider ServiceProvider { get; init; } + + internal class ResponseWriter( + ISamlResultSerializer serializer, + SamlResponseSigner samlResponseSigner, + SamlAssertionEncryptor samlAssertionEncryptor) : IHttpResponseWriter + { + public async Task WriteHttpResponse(SamlResponse result, HttpContext httpContext) + { + var responseXml = serializer.Serialize(result); + + var signedResponseXml = await samlResponseSigner.SignResponse(responseXml, result.ServiceProvider, httpContext.RequestAborted); + + if (result.ServiceProvider.EncryptAssertions) + { + signedResponseXml = samlAssertionEncryptor.EncryptAssertion(signedResponseXml, result.ServiceProvider); + } + + var encodedResponse = Convert.ToBase64String(Encoding.UTF8.GetBytes(signedResponseXml)); + + var html = HttpResponseBindings.GenerateAutoPostForm(SamlConstants.RequestProperties.SAMLResponse, encodedResponse, result.Destination, result.RelayState); + + httpContext.Response.ContentType = "text/html"; + httpContext.Response.Headers.CacheControl = "no-cache, no-store"; + httpContext.Response.Headers.Pragma = "no-cache"; + + await httpContext.Response.WriteAsync(html); + } + } + + internal class Serializer : ISamlResultSerializer + { + public XElement Serialize(SamlResponse toSerialize) + { + var issueInstant = toSerialize.IssueInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); + + var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol); + var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion); + + // Build Status element + var statusCodeElement = new XElement(protocolNs + "StatusCode", + new XAttribute("Value", toSerialize.Status.StatusCode.ToString())); + + if (!string.IsNullOrEmpty(toSerialize.Status.NestedStatusCode)) + { + statusCodeElement.Add( + new XElement(protocolNs + "StatusCode", + new XAttribute("Value", toSerialize.Status.NestedStatusCode))); + } + + var statusElement = new XElement(protocolNs + "Status", statusCodeElement); + + if (!string.IsNullOrEmpty(toSerialize.Status.StatusMessage)) + { + statusElement.Add(new XElement(protocolNs + "StatusMessage", toSerialize.Status.StatusMessage)); + } + + // Build Response element + var responseElement = new XElement(protocolNs + "Response", + new XAttribute("ID", toSerialize.Id.ToString()), + new XAttribute("Version", toSerialize.Version.ToString()), + new XAttribute("IssueInstant", issueInstant), + new XAttribute("Destination", toSerialize.Destination.ToString()), + new XElement(assertionNs + "Issuer", toSerialize.Issuer.ToString()), + statusElement); + + if (toSerialize.InResponseTo != null) + { + responseElement.Add(new XAttribute("InResponseTo", toSerialize.InResponseTo)); + } + + // Add Assertion if present + if (toSerialize.Assertion != null) + { + responseElement.Add(GenerateAssertionElement(toSerialize.Assertion, assertionNs, protocolNs)); + } + + return responseElement; + } + + + private static XElement GenerateAssertionElement(Assertion assertion, XNamespace assertionNs, XNamespace protocolNs) + { + var assertionElement = new XElement(assertionNs + "Assertion", + new XAttribute("ID", assertion.Id.ToString()), + new XAttribute("Version", assertion.Version.ToString()), + new XAttribute("IssueInstant", assertion.IssueInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)), + new XElement(assertionNs + "Issuer", assertion.Issuer.ToString())); + + // Add Subject + if (assertion.Subject != null) + { + assertionElement.Add(GenerateSubjectElement(assertion.Subject, assertionNs)); + } + + // Add Conditions + if (assertion.Conditions != null) + { + assertionElement.Add(GenerateConditionsElement(assertion.Conditions, assertionNs)); + } + + // Add AuthnStatements + foreach (var authnStatement in assertion.AuthnStatements) + { + assertionElement.Add(GenerateAuthnStatementElement(authnStatement, assertionNs)); + } + + // Add AttributeStatements + foreach (var attributeStatement in assertion.AttributeStatements) + { + assertionElement.Add(GenerateAttributeStatementElement(attributeStatement, assertionNs)); + } + + return assertionElement; + } + + private static XElement GenerateSubjectElement(Subject subject, XNamespace assertionNs) + { + var subjectElement = new XElement(assertionNs + "Subject"); + + if (subject.NameId != null) + { + var nameIdElement = new XElement(assertionNs + "NameID", subject.NameId.Value); + + if (!string.IsNullOrEmpty(subject.NameId.Format)) + { + nameIdElement.Add(new XAttribute("Format", subject.NameId.Format)); + } + + if (!string.IsNullOrEmpty(subject.NameId.NameQualifier)) + { + nameIdElement.Add(new XAttribute("NameQualifier", subject.NameId.NameQualifier)); + } + + if (subject.NameId.SPNameQualifier != null) + { + nameIdElement.Add(new XAttribute("SPNameQualifier", subject.NameId.SPNameQualifier)); + } + + subjectElement.Add(nameIdElement); + } + + foreach (var confirmation in subject.SubjectConfirmations) + { + var confirmationElement = new XElement(assertionNs + "SubjectConfirmation", + new XAttribute("Method", confirmation.Method)); + + if (confirmation.Data != null) + { + var dataElement = new XElement(assertionNs + "SubjectConfirmationData"); + + if (confirmation.Data.NotBefore.HasValue) + { + dataElement.Add(new XAttribute("NotBefore", + confirmation.Data.NotBefore.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture))); + } + + if (confirmation.Data.NotOnOrAfter.HasValue) + { + dataElement.Add(new XAttribute("NotOnOrAfter", + confirmation.Data.NotOnOrAfter.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture))); + } + + if (confirmation.Data.Recipient != null) + { + dataElement.Add(new XAttribute("Recipient", confirmation.Data.Recipient.ToString())); + } + + if (confirmation.Data.InResponseTo != null) + { + dataElement.Add(new XAttribute("InResponseTo", confirmation.Data.InResponseTo)); + } + + confirmationElement.Add(dataElement); + } + + subjectElement.Add(confirmationElement); + } + + return subjectElement; + } + + private static XElement GenerateConditionsElement(Conditions conditions, XNamespace assertionNs) + { + var conditionsElement = new XElement(assertionNs + "Conditions"); + + if (conditions.NotBefore.HasValue) + { + conditionsElement.Add(new XAttribute("NotBefore", + conditions.NotBefore.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture))); + } + + if (conditions.NotOnOrAfter.HasValue) + { + conditionsElement.Add(new XAttribute("NotOnOrAfter", + conditions.NotOnOrAfter.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture))); + } + + if (conditions.AudienceRestrictions.Count > 0) + { + var audienceRestrictionElement = new XElement(assertionNs + "AudienceRestriction"); + foreach (var audience in conditions.AudienceRestrictions) + { + audienceRestrictionElement.Add(new XElement(assertionNs + "Audience", audience)); + } + conditionsElement.Add(audienceRestrictionElement); + } + + return conditionsElement; + } + + private static XElement GenerateAuthnStatementElement(AuthnStatement authnStatement, XNamespace assertionNs) + { + var authnStatementElement = new XElement(assertionNs + "AuthnStatement", + new XAttribute("AuthnInstant", authnStatement.AuthnInstant.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture))); + + if (!string.IsNullOrEmpty(authnStatement.SessionIndex)) + { + authnStatementElement.Add(new XAttribute("SessionIndex", authnStatement.SessionIndex)); + } + + if (authnStatement.SessionNotOnOrAfter.HasValue) + { + authnStatementElement.Add(new XAttribute("SessionNotOnOrAfter", + authnStatement.SessionNotOnOrAfter.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture))); + } + + if (authnStatement.AuthnContext != null && !string.IsNullOrEmpty(authnStatement.AuthnContext.AuthnContextClassRef)) + { + var authnContextElement = new XElement(assertionNs + "AuthnContext", + new XElement(assertionNs + "AuthnContextClassRef", authnStatement.AuthnContext.AuthnContextClassRef)); + authnStatementElement.Add(authnContextElement); + } + + return authnStatementElement; + } + + private static XElement GenerateAttributeStatementElement(AttributeStatement attributeStatement, XNamespace assertionNs) + { + var attributeStatementElement = new XElement(assertionNs + "AttributeStatement"); + + foreach (var attribute in attributeStatement.Attributes) + { + var attributeElement = new XElement(assertionNs + "Attribute", + new XAttribute("Name", attribute.Name)); + + if (!string.IsNullOrEmpty(attribute.NameFormat)) + { + attributeElement.Add(new XAttribute("NameFormat", attribute.NameFormat)); + } + + if (!string.IsNullOrEmpty(attribute.FriendlyName)) + { + attributeElement.Add(new XAttribute("FriendlyName", attribute.FriendlyName)); + } + + foreach (var value in attribute.Values) + { + attributeElement.Add(new XElement(assertionNs + "AttributeValue", value)); + } + + attributeStatementElement.Add(attributeElement); + } + + return attributeStatementElement; + } + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlSigninRequest.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlSigninRequest.cs new file mode 100644 index 000000000..96d62e3fd --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/SamlSigninRequest.cs @@ -0,0 +1,50 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +/// +/// Represents a saml signin request, either as a Redirect Binding or a Post Binding. +/// +internal record SamlSigninRequest : SamlRequestBase +{ + public static async ValueTask BindAsync(HttpContext context) + { + var extractor = context.RequestServices.GetRequiredService(); + return await extractor.ExtractAsync(context); + } + + public AuthNRequest AuthNRequest => Request; +} + +internal class SamlSigninRequestExtractor(AuthNRequestParser parser) + : SamlRequestExtractor +{ + protected override AuthNRequest ParseRequest(XDocument xmlDocument) => parser.Parse(xmlDocument); + + protected override SamlSigninRequest CreateResult( + AuthNRequest parsedRequest, + XDocument requestXml, + SamlBinding binding, + string? relayState, + string? signature = null, + string? signatureAlgorithm = null, + string? encodedSamlRequest = null) => new() + { + Request = parsedRequest, + RequestXml = requestXml, + Binding = binding, + RelayState = relayState, + Signature = signature, + SignatureAlgorithm = signatureAlgorithm, + EncodedSamlRequest = encodedSamlRequest + }; +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/StateId.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/StateId.cs new file mode 100644 index 000000000..3b8e4480e --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/StateId.cs @@ -0,0 +1,11 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +internal readonly record struct StateId(Guid Value) +{ + public static StateId NewId() => new(Guid.NewGuid()); + + public override string ToString() => Value.ToString(); +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Status.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Status.cs new file mode 100644 index 000000000..ff1399ffc --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Status.cs @@ -0,0 +1,27 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +/// +/// Represents the status of a SAML Response. +/// +internal record Status +{ + /// + /// Gets or sets the status code indicating the success or failure of the request. + /// + public required string StatusCode { get; set; } + + /// + /// Gets or sets an optional human-readable message providing additional information about the status. + /// + public string? StatusMessage { get; set; } + + /// + /// Gets or sets an optional nested status code for more detailed error information. + /// + public string? NestedStatusCode { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Subject.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Subject.cs new file mode 100644 index 000000000..25681092a --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/Models/Subject.cs @@ -0,0 +1,66 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + + +#nullable enable +using System.Collections.ObjectModel; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +/// +/// Represents a SAML 2.0 Subject element +/// +internal record Subject +{ + /// + /// The name identifier of the subject + /// + public NameIdentifier? NameId { get; set; } + + /// + /// Subject confirmation data + /// + public ReadOnlyCollection SubjectConfirmations { get; init; } = new List().AsReadOnly(); +} + +/// +/// Represents a SAML 2.0 SubjectConfirmation element +/// +internal record SubjectConfirmation +{ + /// + /// The method used to confirm the subject (URI) + /// + public required string Method { get; set; } + + /// + /// Subject confirmation data + /// + public SubjectConfirmationData? Data { get; set; } +} + +/// +/// Represents SAML 2.0 SubjectConfirmationData element +/// +internal record SubjectConfirmationData +{ + /// + /// Time instant before which the subject cannot be confirmed + /// + public DateTime? NotBefore { get; set; } + + /// + /// Time instant at which the subject can no longer be confirmed + /// + public DateTime? NotOnOrAfter { get; set; } + + /// + /// URI of a recipient entity + /// + public Uri? Recipient { get; set; } + + /// + /// ID of a SAML request to which this is a response + /// + public string? InResponseTo { get; set; } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedEndpoint.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedEndpoint.cs new file mode 100644 index 000000000..128cda8dd --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedEndpoint.cs @@ -0,0 +1,60 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Endpoints.Results; +using Duende.IdentityServer.Hosting; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal class SamlIdpInitiatedEndpoint( + SamlIdpInitiatedRequestProcessor requestProcessor, + ILogger logger) : IEndpointHandler +{ + public async Task ProcessAsync(HttpContext context) + { + using var activity = Tracing.BasicActivitySource.StartActivity("SamlIdpInitiatedEndpoint"); + + if (!HttpMethods.IsGet(context.Request.Method)) + { + return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed); + } + + var spEntityId = context.Request.Query["spEntityId"].ToString(); + var relayState = context.Request.Query["relayState"].ToString(); + + if (string.IsNullOrWhiteSpace(spEntityId)) + { + return new ValidationProblemResult("Missing required 'spEntityId' query parameter"); + } + + return await ProcessInternalAsync( + spEntityId, + string.IsNullOrEmpty(relayState) ? null : relayState, + context.RequestAborted); + } + + internal async Task ProcessInternalAsync( + string spEntityId, + string? relayState, + Ct ct = default) + { + logger.StartIdpInitiatedRequest(LogLevel.Debug, spEntityId); + + var result = await requestProcessor.ProcessAsync(spEntityId, relayState, ct); + + if (!result.Success) + { + var error = result.Error; + logger.IdpInitiatedRequestFailed(LogLevel.Information, error.ValidationMessage!); + return new ValidationProblemResult(error.ValidationMessage!); + } + + var success = result.Value; + logger.IdpInitiatedRequestSuccess(LogLevel.Debug, success.RedirectUri); + return new RedirectResult(success.RedirectUri); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedRequestProcessor.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedRequestProcessor.cs new file mode 100644 index 000000000..db5375382 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlIdpInitiatedRequestProcessor.cs @@ -0,0 +1,117 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Stores; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal class SamlIdpInitiatedRequestProcessor( + ISamlServiceProviderStore serviceProviderStore, + ISamlSigninStateStore stateStore, + SamlUrlBuilder samlUrlBuilder, + SamlSigninStateIdCookie stateIdCookie, + IHttpContextAccessor httpContextAccessor, + IOptions options) +{ + private readonly SamlOptions _samlOptions = options.Value; + + internal async Task>> ProcessAsync( + string spEntityId, + string? relayState, + Ct ct = default) + { + var sp = await serviceProviderStore.FindByEntityIdAsync(spEntityId, ct); + if (sp == null) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = $"Service Provider '{spEntityId}' is not registered" + }; + } + + if (!sp.Enabled) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = $"Service Provider '{spEntityId}' is disabled" + }; + } + + if (!sp.AllowIdpInitiated) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = $"Service Provider '{spEntityId}' does not allow IdP-initiated SSO" + }; + } + + if (relayState != null) + { + var relayStateBytes = System.Text.Encoding.UTF8.GetByteCount(relayState); + if (relayStateBytes > _samlOptions.MaxRelayStateLength) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = $"RelayState exceeds maximum length of {_samlOptions.MaxRelayStateLength} bytes" + }; + } + } + + var acsUrl = sp.AssertionConsumerServiceUrls.FirstOrDefault(); + if (acsUrl == null) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = $"Service Provider '{spEntityId}' has no AssertionConsumerServiceUrls configured" + }; + } + + string? relayStateParam = null; + if (!string.IsNullOrEmpty(relayState)) + { + relayStateParam = relayState; + } + + var state = new SamlAuthenticationState + { + Request = null, // No AuthNRequest for IdP-initiated + RelayState = relayStateParam, + ServiceProviderEntityId = sp.EntityId, + AssertionConsumerServiceUrl = acsUrl, + IsIdpInitiated = true, + CreatedUtc = DateTimeOffset.UtcNow + }; + + var storedStateId = await stateStore.StoreSigninRequestStateAsync(state, ct); + stateIdCookie.StoreSamlSigninStateId(storedStateId); + + // Determine redirect based on authentication status + var httpContext = httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("No HttpContext available"); + + var isAuthenticated = httpContext.User.Identity?.IsAuthenticated ?? false; + + Uri redirectUrl; + if (isAuthenticated) + { + redirectUrl = samlUrlBuilder.SamlSignInCallBackUri(); + } + else + { + redirectUrl = samlUrlBuilder.SamlLoginUri(); + } + + return SamlSigninSuccess.CreateRedirectSuccess(redirectUrl); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlNameIdGenerator.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlNameIdGenerator.cs new file mode 100644 index 000000000..6f66b889b --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlNameIdGenerator.cs @@ -0,0 +1,80 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Security.Claims; +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal class SamlNameIdGenerator(IOptions samlOptions, ILogger logger) +{ + private readonly SamlOptions _samlOptions = samlOptions.Value; + + internal NameIdentifier GenerateNameIdentifier( + ClaimsPrincipal user, + SamlServiceProvider samlServiceProvider, + AuthNRequest? request) + { + // Format selection priority: Request > SP Default > IdP Default + var format = request?.NameIdPolicy?.Format + ?? samlServiceProvider.DefaultNameIdFormat + ?? SamlConstants.NameIdentifierFormats.Unspecified; + + logger.UsingNameIdFormat(LogLevel.Debug, format); + + var value = format switch + { + SamlConstants.NameIdentifierFormats.EmailAddress => GetEmailNameId(user), + SamlConstants.NameIdentifierFormats.Persistent => GetPersistentNameId(samlServiceProvider, user), + SamlConstants.NameIdentifierFormats.Transient => Guid.NewGuid().ToString(), + _ => user.GetSubjectId() + }; + + var nameId = new NameIdentifier + { + Value = value, + Format = format, + }; + + if (format == SamlConstants.NameIdentifierFormats.Persistent) + { + nameId.SPNameQualifier = samlServiceProvider.EntityId; + } + + return nameId; + } + + private static string GetEmailNameId(ClaimsPrincipal user) + { + // Try to get email claim + var email = user.FindFirst("email")?.Value + ?? user.FindFirst(ClaimTypes.Email)?.Value; + if (string.IsNullOrEmpty(email)) + { + throw new InvalidOperationException("Could not find email address for authenticated user"); + } + + return email; + } + + private string GetPersistentNameId(SamlServiceProvider samlServiceProvider, ClaimsPrincipal user) + { + var persistentIdClaimType = samlServiceProvider.DefaultPersistentNameIdentifierClaimType ?? + _samlOptions.DefaultPersistentNameIdentifierClaimType; + + var persistentIdentifier = user.FindFirst(persistentIdClaimType); + if (persistentIdentifier == null || string.IsNullOrEmpty(persistentIdentifier.Value)) + { + throw new InvalidOperationException("Could not find persistent identifier for authenticated user"); + } + + return persistentIdentifier.Value; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackEndpoint.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackEndpoint.cs new file mode 100644 index 000000000..d1fdbdbe1 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackEndpoint.cs @@ -0,0 +1,58 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Endpoints.Results; +using Duende.IdentityServer.Hosting; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal class SamlSigninCallbackEndpoint(SamlResponseBuilder responseBuilder, SamlSigninCallbackRequestProcessor samlSigninCallbackRequestProcessor, ILogger logger) : IEndpointHandler +{ + public async Task ProcessAsync(HttpContext context) + { + using var activity = Tracing.BasicActivitySource.StartActivity("SamlSigninCallbackEndpoint"); + + if (!HttpMethods.IsGet(context.Request.Method)) + { + return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed); + } + + return await Process(context.RequestAborted); + } + + internal async Task Process(Ct ct = default) + { + logger.StartSamlSigninCallbackRequest(LogLevel.Debug); + + var result = await samlSigninCallbackRequestProcessor.ProcessAsync(ct); + + if (!result.Success) + { + var error = result.Error; + return error.Type switch + { + SamlRequestErrorType.Validation => + new ValidationProblemResult(error.ValidationMessage!), + + SamlRequestErrorType.Protocol => + responseBuilder.BuildErrorResponse( + error.ProtocolError!.ServiceProvider, + error.ProtocolError.Request, + error.ProtocolError.Error), + + _ => throw new InvalidOperationException($"Unexpected error type: {error.Type}") + }; + } + + return result.Value.SuccessType switch + { + SamlSigninSuccessType.Redirect => new RedirectResult(result.Value.RedirectUri), + SamlSigninSuccessType.Response => result.Value.SamlResponse, + _ => throw new InvalidOperationException($"Unexpected success type: {result.Value.SuccessType}") + }; + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackRequestProcessor.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackRequestProcessor.cs new file mode 100644 index 000000000..ce83e1364 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninCallbackRequestProcessor.cs @@ -0,0 +1,105 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal class SamlSigninCallbackRequestProcessor( + SamlSigninStateIdCookie stateIdCookie, + IUserSession userSession, + ISamlServiceProviderStore serviceProviderStore, + ISamlSigninStateStore stateStore, + SamlUrlBuilder samlUrlBuilder, + SamlResponseBuilder responseBuilder) +{ + internal async Task>> ProcessAsync(Ct ct = default) + { + if (!stateIdCookie.TryGetSamlSigninStateId(out var stateId)) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = "No state id could be found." + }; + } + + var authenticationState = await stateStore.RetrieveSigninRequestStateAsync(stateId.Value, ct); + if (authenticationState == null) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = $"The request {stateId} could not be found." + }; + } + + var user = await userSession.GetUserAsync(ct); + if (user == null || !user.IsAuthenticated()) + { + var loginUri = samlUrlBuilder.SamlLoginUri(); + + return SamlSigninSuccess.CreateRedirectSuccess(loginUri); + } + + var samlServiceProvider = + await serviceProviderStore.FindByEntityIdAsync(authenticationState.ServiceProviderEntityId, ct); + + if (samlServiceProvider is not { Enabled: true }) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = + $"Service Provider '{authenticationState.ServiceProviderEntityId}' is not registered or is disabled" + }; + } + + // Check if this SP already has a session - if so, reuse the SessionIndex + var existingSessions = await userSession.GetSamlSessionListAsync(ct); + var existingSession = existingSessions.FirstOrDefault(s => s.EntityId == samlServiceProvider.EntityId); + string sessionIndex; + + if (existingSession != null) + { + // Reuse existing SessionIndex (e.g., for step-up authentication) + sessionIndex = existingSession.SessionIndex; + } + else + { + // Generate new SessionIndex for this SP + sessionIndex = Guid.NewGuid().ToString("N"); + } + + var samlResponse = await responseBuilder.BuildSuccessResponseAsync(user, samlServiceProvider, authenticationState, sessionIndex, ct); + + if (string.IsNullOrEmpty(samlResponse.Assertion?.Subject?.NameId?.Value)) + { + throw new InvalidOperationException("SAML success response created without a NameId value"); + } + + if (string.IsNullOrEmpty(samlResponse.Assertion?.Subject?.NameId?.Format)) + { + throw new InvalidOperationException("SAML success response created without a NameId format"); + } + + // Track the SAML SP session for logout coordination + var sessionData = new SamlSpSessionData + { + EntityId = samlServiceProvider.EntityId, + SessionIndex = sessionIndex, + NameId = samlResponse.Assertion.Subject.NameId.Value, + NameIdFormat = samlResponse.Assertion.Subject.NameId.Format + }; + await userSession.AddSamlSessionAsync(sessionData, ct); + + stateIdCookie.ClearAuthenticationState(); + + return SamlSigninSuccess.CreateResponseSuccess(samlResponse); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninEndpoint.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninEndpoint.cs new file mode 100644 index 000000000..ccd852773 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninEndpoint.cs @@ -0,0 +1,78 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Endpoints.Results; +using Duende.IdentityServer.Hosting; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal class SamlSigninEndpoint( + SamlSigninRequestExtractor extractor, + SamlSigninRequestProcessor signinRequestProcessor, + ILogger logger, + SamlResponseBuilder responseBuilder) : IEndpointHandler +{ + public async Task ProcessAsync(HttpContext context) + { + using var activity = Tracing.BasicActivitySource.StartActivity("SamlSigninEndpoint"); + + if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsPost(context.Request.Method)) + { + return new StatusCodeResult(System.Net.HttpStatusCode.MethodNotAllowed); + } + + // Extract the SAML request from query string (GET/Redirect) or form (POST) + var signinRequest = await extractor.ExtractAsync(context); + + return await ProcessSpInitiatedSignin(signinRequest, context.RequestAborted); + } + + internal async Task ProcessSpInitiatedSignin( + SamlSigninRequest signinRequest, + Ct ct = default) + { + logger.StartSamlSigninRequest(LogLevel.Debug); + + var result = await signinRequestProcessor.ProcessAsync(signinRequest, ct); + + if (!result.Success) + { + var error = result.Error; + return error.Type switch + { + SamlRequestErrorType.Validation => HandleValidationError(error), + SamlRequestErrorType.Protocol => HandleProtocolError(error), + _ => throw new InvalidOperationException($"Unexpected error type: {error.Type}") + }; + } + + var success = result.Value; + logger.SamlSigninSuccess(LogLevel.Debug, success.RedirectUri); + return new RedirectResult(success.RedirectUri); + } + + private ValidationProblemResult HandleValidationError(SamlRequestError error) + { + logger.SamlSigninValidationError(LogLevel.Information, error.ValidationMessage!); + return new ValidationProblemResult(error.ValidationMessage!); + } + + private SamlErrorResponse HandleProtocolError(SamlRequestError error) + { + var protocolError = error.ProtocolError!; + logger.SamlSigninProtocolError( + LogLevel.Information, + protocolError.Error.StatusCode, + protocolError.Error.Message); + + return responseBuilder.BuildErrorResponse( + protocolError.ServiceProvider, + protocolError.Request, + protocolError.Error); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninRequestProcessor.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninRequestProcessor.cs new file mode 100644 index 000000000..fe37d5293 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninRequestProcessor.cs @@ -0,0 +1,197 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal class SamlSigninRequestProcessor( + ISamlServiceProviderStore serviceProviderStore, + ISamlSigninInteractionResponseGenerator interactionResponseGenerator, + ISamlSigninStateStore stateStore, + SamlUrlBuilder samlUrlBuilder, + TimeProvider timeProvider, + IOptions options, + IServerUrls serverUrls, + SamlSigninStateIdCookie stateIdCookie, + SamlRequestSignatureValidator signatureValidator, + SamlRequestValidator requestValidator, + ILogger logger) + : SamlRequestProcessorBase(serviceProviderStore, + options, + requestValidator, + signatureValidator, + logger, + serverUrls.GetAbsoluteUrl(options.Value.UserInteraction.Route + options.Value.UserInteraction.SignInPath)) +{ + protected override async Task>> ProcessValidatedRequestAsync( + SamlServiceProvider sp, + SamlSigninRequest signinRequest, + Ct ct = default) + { + var authNRequest = signinRequest.AuthNRequest; + + var getAcsUrlResult = GetAcsUrl(sp, authNRequest); + if (!getAcsUrlResult.Success) + { + return getAcsUrlResult.Error; + } + + var result = await interactionResponseGenerator.ProcessInteractionAsync(sp, authNRequest, ct); + + if (result.IsError) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Protocol, + ProtocolError = new SamlProtocolError(sp, signinRequest, result.Error!) + }; + } + + var assertionConsumerServiceUrl = getAcsUrlResult.Value; + switch (result.ResultType) + { + case SamlInteractionResponseType.Login: + { + await StoreStateAsync(signinRequest, assertionConsumerServiceUrl, authNRequest, sp, ct); + var redirectUri = samlUrlBuilder.SamlLoginUri(); + + return SamlSigninSuccess.CreateRedirectSuccess(redirectUri); + } + case SamlInteractionResponseType.AlreadyAuthenticated: + { + await StoreStateAsync(signinRequest, assertionConsumerServiceUrl, authNRequest, sp, ct); + var samlCallBackUri = samlUrlBuilder.SamlSignInCallBackUri(); + + return SamlSigninSuccess.CreateRedirectSuccess(samlCallBackUri); + } + case SamlInteractionResponseType.Consent: + { + await StoreStateAsync(signinRequest, assertionConsumerServiceUrl, authNRequest, sp, ct); + var samlConsentUri = samlUrlBuilder.SamlConsentUri(); + + return SamlSigninSuccess.CreateRedirectSuccess(samlConsentUri); + } + case SamlInteractionResponseType.CreateAccount: + throw new NotImplementedException("Create account isn't implemented yet"); + default: + throw new InvalidOperationException("Unexpected result type: " + result.ResultType); + } + } + + private static Result> GetAcsUrl(SamlServiceProvider serviceProvider, + AuthNRequest authNRequest) + { + if (authNRequest.AssertionConsumerServiceUrl != null) + { + if (!serviceProvider.AssertionConsumerServiceUrls.Contains(authNRequest.AssertionConsumerServiceUrl)) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = + $"AssertionConsumerServiceUrl '{authNRequest.AssertionConsumerServiceUrl}' is not valid" + }; + } + + return authNRequest.AssertionConsumerServiceUrl; + } + + if (authNRequest.AssertionConsumerServiceIndex != null) + { + if (authNRequest.AssertionConsumerServiceIndex.Value < 0 || + authNRequest.AssertionConsumerServiceIndex.Value >= serviceProvider.AssertionConsumerServiceUrls.Count) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = + $"AssertionConsumerServiceIndex '{authNRequest.AssertionConsumerServiceIndex}' is not valid" + }; + } + + return serviceProvider.AssertionConsumerServiceUrls.ElementAt(authNRequest.AssertionConsumerServiceIndex + .Value); + } + + if (serviceProvider.AssertionConsumerServiceUrls.Count == 0) + { + return new SamlRequestError + { + Type = SamlRequestErrorType.Validation, + ValidationMessage = + $"The Service Provider '{serviceProvider.EntityId}' does not have any configured Assertion Consumer Service URLs" + }; + } + + return serviceProvider.AssertionConsumerServiceUrls.First(); + } + + protected override bool RequireSignature(SamlServiceProvider sp) => sp.RequireSignedAuthnRequests; + + protected override SamlRequestError? ValidateMessageSpecific(SamlServiceProvider sp, SamlSigninRequest signinRequest) + { + var authNRequest = signinRequest.AuthNRequest; + + // AuthNRequest-specific validation (NameIdPolicy) + if (authNRequest.NameIdPolicy?.Format != null) + { + var requestedFormat = authNRequest.NameIdPolicy.Format; + var supportedFormats = SamlOptions.SupportedNameIdFormats; + + if (!supportedFormats.Contains(requestedFormat)) + { + Logger.RequestedNameIdFormatNotSupported(LogLevel.Debug, requestedFormat); + + var samlError = new SamlError + { + StatusCode = SamlStatusCodes.Responder, + SubStatusCode = SamlStatusCodes.InvalidNameIdPolicy, + Message = $"Requested NameID format '{requestedFormat}' is not supported by this IdP" + }; + return new SamlRequestError + { + Type = SamlRequestErrorType.Protocol, + ProtocolError = new SamlProtocolError(sp, signinRequest, samlError) + }; + } + + Logger.NameIdPolicyParsed(LogLevel.Debug, authNRequest.NameIdPolicy.Format, authNRequest.NameIdPolicy.SPNameQualifier); + } + + return null; + } + + private async Task StoreStateAsync( + SamlSigninRequest signinRequest, + Uri assertionConsumerServiceUrl, + AuthNRequest authNRequest, + SamlServiceProvider sp, + Ct ct = default) + { + var state = new SamlAuthenticationState + { + Request = authNRequest, + ServiceProviderEntityId = sp.EntityId, + RelayState = signinRequest.RelayState, + IsIdpInitiated = false, + CreatedUtc = timeProvider.GetUtcNow(), + AssertionConsumerServiceUrl = assertionConsumerServiceUrl + }; + + var stateId = await stateStore.StoreSigninRequestStateAsync(state, ct); + + stateIdCookie.StoreSamlSigninStateId(stateId); + } +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninResults.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninResults.cs new file mode 100644 index 000000000..c246165d4 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninResults.cs @@ -0,0 +1,35 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal record SamlSigninSuccess +{ + private SamlSigninSuccess(Uri redirectUri) + { + RedirectUri = redirectUri; + SuccessType = SamlSigninSuccessType.Redirect; + } + + private SamlSigninSuccess(SamlResponse response) + { + SamlResponse = response; + SuccessType = SamlSigninSuccessType.Response; + } + + public SamlSigninSuccessType SuccessType { get; private set; } + public Uri RedirectUri { get; private set; } = null!; + public SamlResponse SamlResponse { get; private set; } = null!; + + public static SamlSigninSuccess CreateRedirectSuccess(Uri redirectUri) => new(redirectUri); + + public static SamlSigninSuccess CreateResponseSuccess(SamlResponse samlResponse) => new(samlResponse); +} + +internal enum SamlSigninSuccessType +{ + Redirect, + Response +} diff --git a/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninStateIdCookie.cs b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninStateIdCookie.cs new file mode 100644 index 000000000..4b8aca936 --- /dev/null +++ b/identity-server/src/IdentityServer/Internal/Saml/SingleSignin/SamlSigninStateIdCookie.cs @@ -0,0 +1,67 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Diagnostics.CodeAnalysis; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Microsoft.AspNetCore.Http; + +namespace Duende.IdentityServer.Internal.Saml.SingleSignin; + +internal class SamlSigninStateIdCookie(IHttpContextAccessor httpContextAccessor) +{ + private const string CookieName = "__Host-idsrv.SamlSigninState"; + private static readonly TimeSpan CookieLifetime = TimeSpan.FromMinutes(5); + + private HttpContext HttpContext => httpContextAccessor.HttpContext + ?? throw new InvalidOperationException("HttpContext is not available."); + + internal void StoreSamlSigninStateId(StateId stateId) + { + var cookieOptions = new CookieOptions + { + HttpOnly = true, + Secure = true, + // Note: Safari does not set the cookie on a redirect if this is set to Strict + SameSite = SameSiteMode.Lax, + IsEssential = true, + Expires = DateTimeOffset.UtcNow.Add(CookieLifetime) + }; + + HttpContext.Response.Cookies.Append(CookieName, stateId.Value.ToString(), cookieOptions); + } + + internal bool TryGetSamlSigninStateId([NotNullWhen(true)] out StateId? stateId) + { + stateId = null; + + if (!HttpContext.Request.Cookies.TryGetValue(CookieName, out var rawStateId) || string.IsNullOrEmpty(rawStateId)) + { + return false; + } + + try + { + if (!Guid.TryParse(rawStateId, out var guid)) + { + return false; + } + + stateId = new StateId(guid); + return true; + } +#pragma warning disable CA1031 + catch (Exception) +#pragma warning restore CA1031 + { + return false; + } + } + + internal void ClearAuthenticationState() => HttpContext.Response.Cookies.Delete(CookieName, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + IsEssential = true + }); +} diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/EndpointUsageDiagnosticEntry.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/EndpointUsageDiagnosticEntry.cs index 9ccc5e428..065fecc63 100644 --- a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/EndpointUsageDiagnosticEntry.cs +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/EndpointUsageDiagnosticEntry.cs @@ -25,6 +25,12 @@ internal class EndpointUsageDiagnosticEntry : IDiagnosticEntry, IDisposable private long _token; private long _userInfo; private long _oAuthMetadata; + private long _samlMetadata; + private long _samlSignIn; + private long _samlSignInCallback; + private long _samlIdPInitiated; + private long _samlLogout; + private long _samlLogoutCallback; private long _other; private readonly MeterListener _meterListener; @@ -65,6 +71,12 @@ internal class EndpointUsageDiagnosticEntry : IDiagnosticEntry, IDisposable writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.Token.EnsureLeadingSlash(), _token); writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.UserInfo.EnsureLeadingSlash(), _userInfo); writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.OAuthMetadata.EnsureLeadingSlash(), _oAuthMetadata); + writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.SamlMetadata.EnsureLeadingSlash(), _samlMetadata); + writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.SamlSignin.EnsureLeadingSlash(), _samlSignIn); + writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.SamlSigninCallback.EnsureLeadingSlash(), _samlSignInCallback); + writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.SamlIdpInitiated.EnsureLeadingSlash(), _samlIdPInitiated); + writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.SamlLogout.EnsureLeadingSlash(), _samlLogout); + writer.WriteNumber(IdentityServerConstants.ProtocolRoutePaths.SamlLogoutCallback.EnsureLeadingSlash(), _samlLogoutCallback); writer.WriteNumber("other", _other); writer.WriteEndObject(); @@ -141,6 +153,24 @@ internal class EndpointUsageDiagnosticEntry : IDiagnosticEntry, IDisposable case { } s when s.StartsWith(IdentityServerConstants.ProtocolRoutePaths.OAuthMetadata, StringComparison.OrdinalIgnoreCase): Interlocked.Increment(ref _oAuthMetadata); break; + case IdentityServerConstants.ProtocolRoutePaths.SamlMetadata: + Interlocked.Increment(ref _samlMetadata); + break; + case IdentityServerConstants.ProtocolRoutePaths.SamlSignin: + Interlocked.Increment(ref _samlSignIn); + break; + case IdentityServerConstants.ProtocolRoutePaths.SamlSigninCallback: + Interlocked.Increment(ref _samlSignInCallback); + break; + case IdentityServerConstants.ProtocolRoutePaths.SamlIdpInitiated: + Interlocked.Increment(ref _samlIdPInitiated); + break; + case IdentityServerConstants.ProtocolRoutePaths.SamlLogout: + Interlocked.Increment(ref _samlLogout); + break; + case IdentityServerConstants.ProtocolRoutePaths.SamlLogoutCallback: + Interlocked.Increment(ref _samlLogoutCallback); + break; default: Interlocked.Increment(ref _other); break; diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/RegisteredImplementationsDiagnosticEntry.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/RegisteredImplementationsDiagnosticEntry.cs index e14422df9..29aab2a64 100644 --- a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/RegisteredImplementationsDiagnosticEntry.cs +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/RegisteredImplementationsDiagnosticEntry.cs @@ -6,7 +6,10 @@ using Duende.IdentityServer.Events; using Duende.IdentityServer.Hosting; using Duende.IdentityServer.Hosting.DynamicProviders; using Duende.IdentityServer.Internal; +using Duende.IdentityServer.Internal.Saml; +using Duende.IdentityServer.Internal.Saml.SingleLogout; using Duende.IdentityServer.ResponseHandling; +using Duende.IdentityServer.Saml; using Duende.IdentityServer.Services; using Duende.IdentityServer.Services.Default; using Duende.IdentityServer.Services.KeyManagement; @@ -52,6 +55,15 @@ internal class RegisteredImplementationsDiagnosticEntry(ServiceCollectionAccesso new(typeof(IUserInfoResponseGenerator), [typeof(UserInfoResponseGenerator)]), ] }, + { + "SAML", [ + new(typeof(ISamlClaimsMapper), []), + new(typeof(ISamlFrontChannelLogout), [typeof(SamlHttpPostFrontChannelLogout), typeof(SamlHttpRedirectFrontChannelLogout)]), + new(typeof(ISamlInteractionService),[]), + new(typeof(ISamlLogoutNotificationService), [typeof(NopSamlLogoutNotificationService)]), + new(typeof(ISamlSigninInteractionResponseGenerator),[]), + ] + }, { "Services", [ new(typeof(IAutomaticKeyManagerKeyStore), [typeof(AutomaticKeyManagerKeyStore)]), @@ -112,6 +124,7 @@ internal class RegisteredImplementationsDiagnosticEntry(ServiceCollectionAccesso new(typeof(IReferenceTokenStore), [typeof(DefaultReferenceTokenStore)]), new(typeof(IRefreshTokenStore), [typeof(DefaultRefreshTokenStore)]), new(typeof(IResourceStore), [typeof(EmptyResourceStore)]), + new(typeof(ISamlServiceProviderStore), [typeof(EmptySamlServiceProviderStore)]), new(typeof(IServerSideSessionsMarker), []), new(typeof(IServerSideSessionStore),[]), new(typeof(IServerSideTicketStore),[]), diff --git a/identity-server/src/IdentityServer/Models/Contexts/LogoutNotificationContext.cs b/identity-server/src/IdentityServer/Models/Contexts/LogoutNotificationContext.cs index cd733328e..d2cfacee4 100644 --- a/identity-server/src/IdentityServer/Models/Contexts/LogoutNotificationContext.cs +++ b/identity-server/src/IdentityServer/Models/Contexts/LogoutNotificationContext.cs @@ -4,6 +4,8 @@ #nullable enable +using Duende.IdentityServer.Saml.Models; + namespace Duende.IdentityServer.Models; /// @@ -31,6 +33,13 @@ public class LogoutNotificationContext /// public IEnumerable ClientIds { get; set; } = default!; + /// + /// The SAML Service Provider sessions that the user has authenticated to. + /// Contains full session data including NameId, SessionIndex, and NameIdFormat + /// required to construct logout requests. + /// + public IEnumerable SamlSessions { get; set; } = []; + /// /// Indicates why the user's session ended, if known. /// diff --git a/identity-server/src/IdentityServer/Models/Messages/LogoutRequest.cs b/identity-server/src/IdentityServer/Models/Messages/LogoutRequest.cs index 119cd8a32..b65e06b32 100644 --- a/identity-server/src/IdentityServer/Models/Messages/LogoutRequest.cs +++ b/identity-server/src/IdentityServer/Models/Messages/LogoutRequest.cs @@ -7,6 +7,7 @@ using System.Collections.Specialized; using Duende.IdentityModel; using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Saml.Models; using Duende.IdentityServer.Validation; namespace Duende.IdentityServer.Models; @@ -47,6 +48,7 @@ public class LogoutMessage SubjectId = request.Subject?.GetSubjectId(); SessionId = request.SessionId; ClientIds = request.ClientIds; + SamlSessions = request.SamlSessions; UiLocales = request.UiLocales; if (request.PostLogOutUri != null) @@ -90,6 +92,30 @@ public class LogoutMessage /// public IEnumerable? ClientIds { get; set; } + /// + /// Gets or sets the EntityId of the SAML Service Provider that initiated logout. + /// Null if this is not a SAML-initiated logout. + /// + public string? SamlServiceProviderEntityId { get; set; } + + /// + /// Gets or sets the ID of the SAML LogoutRequest being responded to. + /// Null if this is not a SAML-initiated logout. + /// + public string? SamlLogoutRequestId { get; set; } + + /// + /// Gets or sets the SAML RelayState parameter to return to the SP. + /// Null if this is not a SAML-initiated logout or no RelayState was provided. + /// + public string? SamlRelayState { get; set; } + + /// + /// SAML Service Provider sessions for the user at logout time. + /// Contains full session data required for logout notifications. + /// + public IEnumerable? SamlSessions { get; set; } + /// /// The UI locales. /// @@ -103,7 +129,10 @@ public class LogoutMessage /// /// Flag to indicate if the payload contains useful information or not to avoid serialization. /// - internal bool ContainsPayload => ClientId.IsPresent() || ClientIds?.Any() == true; + internal bool ContainsPayload => ClientId.IsPresent() + || ClientIds?.Any() == true + || SamlServiceProviderEntityId.IsPresent() + || SamlSessions?.Any() == true; } /// @@ -127,6 +156,10 @@ public class LogoutRequest SessionId = message.SessionId; ClientIds = message.ClientIds; UiLocales = message.UiLocales; + SamlServiceProviderEntityId = message.SamlServiceProviderEntityId; + SamlLogoutRequestId = message.SamlLogoutRequestId; + SamlRelayState = message.SamlRelayState; + SamlSessions = message.SamlSessions; Parameters = message.Parameters.FromFullDictionary(); } @@ -163,6 +196,30 @@ public class LogoutRequest /// public IEnumerable? ClientIds { get; set; } + /// + /// Gets or sets the EntityId of the SAML Service Provider that initiated logout. + /// Null if this is not a SAML-initiated logout. + /// + public string? SamlServiceProviderEntityId { get; set; } + + /// + /// Gets or sets the ID of the SAML LogoutRequest being responded to. + /// Null if this is not a SAML-initiated logout. + /// + public string? SamlLogoutRequestId { get; set; } + + /// + /// Gets or sets the SAML RelayState parameter to return to the SP. + /// Null if this is not a SAML-initiated logout or no RelayState was provided. + /// + public string? SamlRelayState { get; set; } + + /// + /// SAML Service Provider sessions for the user at logout time. + /// Contains full session data required for logout notifications. + /// + public IEnumerable? SamlSessions { get; set; } + /// /// The UI locales. /// diff --git a/identity-server/src/IdentityServer/Saml/ISamlClaimsMapper.cs b/identity-server/src/IdentityServer/Saml/ISamlClaimsMapper.cs new file mode 100644 index 000000000..a6c4749ee --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/ISamlClaimsMapper.cs @@ -0,0 +1,23 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Saml.Models; + +namespace Duende.IdentityServer.Saml; + +/// +/// Service for customizing how claims are mapped to SAML attributes. +/// If registered, this service completely replaces the default mapping logic. +/// +public interface ISamlClaimsMapper +{ + /// + /// Maps claims to SAML attributes. + /// + /// This method is called when a custom mapper is registered and completely + /// replaces the default mapping behavior (global + service provider mappings). + /// + /// Context with information about the authentication request for which claims need to be mapped + /// The mapped SAML attributes + Task> MapClaimsAsync(SamlClaimsMappingContext claimsMappingContext); +} diff --git a/identity-server/src/IdentityServer/Saml/ISamlFrontChannelLogout.cs b/identity-server/src/IdentityServer/Saml/ISamlFrontChannelLogout.cs new file mode 100644 index 000000000..603b1dca6 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/ISamlFrontChannelLogout.cs @@ -0,0 +1,18 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Saml; + +public interface ISamlFrontChannelLogout +{ + SamlBinding SamlBinding { get; } + + Uri Destination { get; } + + string EncodedContent { get; } + + string? RelayState { get; } +} diff --git a/identity-server/src/IdentityServer/Saml/ISamlInteractionService.cs b/identity-server/src/IdentityServer/Saml/ISamlInteractionService.cs new file mode 100644 index 000000000..fa87f56eb --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/ISamlInteractionService.cs @@ -0,0 +1,30 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Saml.Models; + +namespace Duende.IdentityServer.Saml; + +/// +/// Provide services to be used by the user interface to communicate with IdentityServer for SAML flows. +/// +public interface ISamlInteractionService +{ + /// + /// Gets the SAML authentication request context from the current request's state cookie. + /// Returns null if no SAML authentication is in progress. + /// + Task GetAuthenticationRequestContextAsync(Ct ct = default); + + /// + /// Stores whether the user met the requirements of the RequestedAuthnContext in the + /// AuthNRequest. If the value is set to false, the generated response will include a second-level + /// status code of urn:oasis:names:tc:SAML:2.0:status:NoAuthnContext per section 3.3.2.2.1 of the + /// core spec. + /// + /// Whether the requirements of the RequestedAuthnContext were met. + /// Cancellation token + /// + Task StoreRequestedAuthnContextResultAsync(bool requestedAuthnContextRequirementsWereMet, Ct ct = default); +} diff --git a/identity-server/src/IdentityServer/Saml/ISamlLogoutNotificationService.cs b/identity-server/src/IdentityServer/Saml/ISamlLogoutNotificationService.cs new file mode 100644 index 000000000..3d0f8b5bf --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/ISamlLogoutNotificationService.cs @@ -0,0 +1,16 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Saml; + +public interface ISamlLogoutNotificationService +{ + /// + /// Builds the URLs needed for front-channel logout notification. + /// + /// The context for the logout notification. + /// The cancellation token. + Task> GetSamlFrontChannelLogoutsAsync(LogoutNotificationContext context, Ct ct); +} diff --git a/identity-server/src/IdentityServer/Saml/ISamlSigninInteractionResponseGenerator.cs b/identity-server/src/IdentityServer/Saml/ISamlSigninInteractionResponseGenerator.cs new file mode 100644 index 000000000..28492afcc --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/ISamlSigninInteractionResponseGenerator.cs @@ -0,0 +1,12 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; + +namespace Duende.IdentityServer.Saml; + +public interface ISamlSigninInteractionResponseGenerator +{ + Task ProcessInteractionAsync(SamlServiceProvider sp, AuthNRequest request, Ct ct = default); +} diff --git a/identity-server/src/IdentityServer/Saml/Models/AuthNRequest.cs b/identity-server/src/IdentityServer/Saml/Models/AuthNRequest.cs new file mode 100644 index 000000000..327f64670 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/AuthNRequest.cs @@ -0,0 +1,116 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Represents a SAML 2.0 AuthnRequest message sent by a Service Provider to request authentication. +/// +public record AuthNRequest : ISamlRequest +{ + public static string MessageName => "SAML signin request"; + + /// + /// Gets or sets the unique identifier for this request. + /// Must be unique across all requests from this SP. + /// + public required string Id { get; set; } + + /// + /// Gets or sets the SAML version. Must be "2.0". + /// + public required string Version { get; set; } + + /// + /// Gets or sets the time instant of issue in UTC. + /// + public required DateTime IssueInstant { get; set; } + + /// + /// Gets or sets the URI reference indicating the destination to which this request is directed. + /// Should match the IdP's SSO endpoint URL. + /// + public Uri? Destination { get; set; } + + /// + /// Gets or sets the consent obtained from the principal for sending this request. + /// + public string? Consent { get; set; } + + /// + /// Gets or sets the entity identifier of the Service Provider making this request. + /// This is the SP's entity ID from its metadata. + /// + public required string Issuer { get; set; } + + /// + /// Gets or sets a value indicating whether the IdP must freshly obtain the authentication (not from cache). + /// If true, the IdP must reauthenticate the user even if a session exists. + /// Default: false + /// + public bool ForceAuthn { get; set; } + + /// + /// Gets or sets a value indicating whether the IdP should not actively interact with the user. + /// If true, the IdP should not show UI to the user (authentication must be passive). + /// Default: false + /// + public bool IsPassive { get; set; } + + /// + /// Gets or sets the URL of the ACS endpoint where the response should be sent (optional). + /// If specified, overrides the default ACS URL from SP metadata. + /// + public Uri? AssertionConsumerServiceUrl { get; set; } + + /// + /// Gets or sets the index of the ACS endpoint where the response should be sent (optional). + /// References an indexed ACS endpoint in the SP's metadata. + /// + public int? AssertionConsumerServiceIndex { get; set; } + + /// + /// Gets or sets the SAML protocol binding to use for the response (optional). + /// Example: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + /// + public SamlBinding? ProtocolBinding { get; set; } + + /// + /// Gets or sets the requested authentication context constraints. + /// Specifies requirements/preferences for the authentication context the IdP should use. + /// Optional - if null, no specific context is required. + /// + public RequestedAuthnContext? RequestedAuthnContext { get; set; } + + /// + /// Gets or sets the requested NameID policy constraints from the SP. + /// Specifies the format and characteristics of the name identifier to return. + /// Optional - if null, no specific policy is requested. + /// + public NameIdPolicy? NameIdPolicy { get; set; } + + internal static class AttributeNames + { + public const string Id = "ID"; + public const string Version = "Version"; + public const string IssueInstant = "IssueInstant"; + public const string Destination = "Destination"; + public const string Consent = "Consent"; + public const string Issuer = "Issuer"; + public const string ForceAuthn = "ForceAuthn"; + public const string IsPassive = "IsPassive"; + public const string AssertionConsumerServiceUrl = "AssertionConsumerServiceURL"; + public const string AssertionConsumerServiceIndex = "AssertionConsumerServiceIndex"; + public const string ProtocolBinding = "ProtocolBinding"; + } + + internal static class ElementNames + { + public const string RequestedAuthnContext = "RequestedAuthnContext"; + public const string NameIdPolicy = "NameIDPolicy"; + } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/AuthnContextComparison.cs b/identity-server/src/IdentityServer/Saml/Models/AuthnContextComparison.cs new file mode 100644 index 000000000..88c7c1a2d --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/AuthnContextComparison.cs @@ -0,0 +1,66 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Specifies the comparison method to apply to requested authentication contexts. +/// SAML 2.0 Core Section 3.3.2.2.1 +/// +public enum AuthnContextComparison +{ + /// + /// The authentication context must match exactly one of the requested contexts. + /// + Exact, + + /// + /// The authentication context must be at least as strong as one of the requested contexts. + /// + Minimum, + + /// + /// The authentication context must be no stronger than one of the requested contexts. + /// + Maximum, + + /// + /// The authentication context must be stronger than all requested contexts. + /// + Better +} + +/// +/// Extension methods for AuthnContextComparison enum +/// +public static class AuthnContextComparisonExtensions +{ + /// + /// Parses a string value into an AuthnContextComparison enum. + /// Defaults to Exact if value is null, empty, or invalid. + /// + public static AuthnContextComparison Parse(string? value) => + value?.ToUpperInvariant() switch + { + "EXACT" => AuthnContextComparison.Exact, + "MINIMUM" => AuthnContextComparison.Minimum, + "MAXIMUM" => AuthnContextComparison.Maximum, + "BETTER" => AuthnContextComparison.Better, + null => AuthnContextComparison.Exact, // Default per SAML spec + _ => throw new ArgumentException($"Unknown {nameof(AuthnContextComparison)}: {value}") + }; + + /// + /// Converts an AuthnContextComparison enum to its XML attribute value. + /// + public static string ToAttributeValue(this AuthnContextComparison comparison) => + comparison switch + { + AuthnContextComparison.Exact => "exact", + AuthnContextComparison.Minimum => "minimum", + AuthnContextComparison.Maximum => "maximum", + AuthnContextComparison.Better => "better", + _ => "exact" + }; +} diff --git a/identity-server/src/IdentityServer/Saml/Models/IdpInitiatedResult.cs b/identity-server/src/IdentityServer/Saml/Models/IdpInitiatedResult.cs new file mode 100644 index 000000000..3d06fa5e3 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/IdpInitiatedResult.cs @@ -0,0 +1,40 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Result of initiating an IdP-initiated SAML SSO flow. +/// +public class IdpInitiatedResult +{ + /// + /// Gets whether the initiation was successful. + /// + public bool Success { get; init; } + + /// + /// Gets the URL to redirect to (login or callback endpoint). + /// Only set when Success is true. + /// + public Uri? RedirectUrl { get; init; } + + /// + /// Gets the validation error message. + /// Only set when Success is false. + /// + public string? ErrorMessage { get; init; } + + /// + /// Creates a successful result with a redirect URL. + /// + public static IdpInitiatedResult Succeed(Uri redirectUrl) => + new() { Success = true, RedirectUrl = redirectUrl }; + + /// + /// Creates a failed result with an error message. + /// + public static IdpInitiatedResult Fail(string errorMessage) => + new() { Success = false, ErrorMessage = errorMessage }; +} diff --git a/identity-server/src/IdentityServer/Saml/Models/NameIdPolicy.cs b/identity-server/src/IdentityServer/Saml/Models/NameIdPolicy.cs new file mode 100644 index 000000000..cfba2ac97 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/NameIdPolicy.cs @@ -0,0 +1,33 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Represents the NameIDPolicy element from a SAML AuthnRequest. +/// Specifies constraints on the name identifier to be returned. +/// SAML 2.0 Core Section 3.4.1.1 +/// +public record NameIdPolicy +{ + /// + /// Gets the requested name identifier format. + /// Example: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + /// If null, no specific format is requested. + /// + public string? Format { get; init; } + + /// + /// Gets the SPNameQualifier to use in the returned NameID. + /// Typically the SP's entity ID, but SP can request a different value. + /// If null, IdP should use SP's entity ID. + /// + public string? SPNameQualifier { get; init; } + + internal static class AttributeNames + { + public const string Format = "Format"; + public const string SPNameQualifier = "SPNameQualifier"; + } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/RequestedAuthnContext.cs b/identity-server/src/IdentityServer/Saml/Models/RequestedAuthnContext.cs new file mode 100644 index 000000000..ff2fe5bd9 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/RequestedAuthnContext.cs @@ -0,0 +1,34 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Represents the RequestedAuthnContext element from a SAML AuthnRequest. +/// Specifies requirements or preferences for the authentication context the IdP should use. +/// SAML 2.0 Core Section 3.3.2.2.1 +/// +public record RequestedAuthnContext +{ + /// + /// Gets the authentication context class references requested by the SP. + /// URIs identifying authentication context classes (e.g., urn:oasis:names:tc:SAML:2.0:ac:classes:Password). + /// + public required IReadOnlyCollection AuthnContextClassRefs { get; init; } + + /// + /// Gets the comparison method to apply to the requested contexts. + /// Default: Exact + /// + public AuthnContextComparison Comparison { get; init; } = AuthnContextComparison.Exact; + + internal static class ElementNames + { + public const string AuthnContextClassRef = "AuthnContextClassRef"; + } + + internal static class AttributeNames + { + public const string Comparison = "Comparison"; + } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlAttribute.cs b/identity-server/src/IdentityServer/Saml/Models/SamlAttribute.cs new file mode 100644 index 000000000..65663d737 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlAttribute.cs @@ -0,0 +1,36 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Represents a SAML 2.0 Attribute element +/// +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix - SamlAttribute is the standard SAML term +public record SamlAttribute +#pragma warning restore CA1711 +{ + /// + /// Attribute name (e.g., "urn:oid:0.9.2342.19200300.100.1.1" or "email") + /// + public required string Name { get; set; } + + /// + /// Attribute name format URI (e.g., "urn:oasis:names:tc:SAML:2.0:attrname-format:uri") + /// + public string? NameFormat { get; set; } + + /// + /// Human-readable friendly name for the attribute (e.g., "uid", "email"). + /// Optional but recommended for debugging and some SP compatibility. + /// + public string? FriendlyName { get; set; } + + /// + /// Attribute values (can be multi-valued) + /// +#pragma warning disable CA2227, CA1002 // Collection properties should be read only and use Collection - List is by design for mutability and performance + public List Values { get; set; } = []; +#pragma warning restore CA2227, CA1002 +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlAuthenticationRequest.cs b/identity-server/src/IdentityServer/Saml/Models/SamlAuthenticationRequest.cs new file mode 100644 index 000000000..01b8c6354 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlAuthenticationRequest.cs @@ -0,0 +1,42 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Represents contextual information about a SAML authentication request. +/// +public class SamlAuthenticationRequest +{ + /// + /// Gets or sets the Service Provider making the authentication request. + /// + public required SamlServiceProvider ServiceProvider { get; set; } + + /// + /// Gets or sets the original SAML AuthnRequest. + /// Will be null for IdP-initiated SSO flows. + /// + public AuthNRequest? AuthNRequest { get; set; } + + /// + /// Gets the requested authentication context from the AuthNRequest. + /// This is a convenience property that accesses AuthNRequest.RequestedAuthnContext. + /// + public RequestedAuthnContext? RequestedAuthnContext => AuthNRequest?.RequestedAuthnContext; + + /// + /// Gets or sets the RelayState parameter to be echoed back to the Service Provider. + /// For IdP-initiated SSO, this typically contains the target URL at the SP. + /// + public string? RelayState { get; set; } + + /// + /// Gets or sets a value indicating whether this is an IdP-initiated SSO flow. + /// If true, there was no AuthnRequest and the response will be unsolicited. + /// + public bool IsIdpInitiated { get; set; } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlClaimsMappingContext.cs b/identity-server/src/IdentityServer/Saml/Models/SamlClaimsMappingContext.cs new file mode 100644 index 000000000..7f6674216 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlClaimsMappingContext.cs @@ -0,0 +1,21 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Claims; +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Saml.Models; + +public record SamlClaimsMappingContext +{ + /// + /// The claims issued for the current user to be mapped to SAML Attributes + /// for inclusion in the SAMLResponse. + /// + public IEnumerable UserClaims { get; init; } = []; + + /// + /// The Service Provider which initiated the Authn request. + /// + public required SamlServiceProvider ServiceProvider { get; init; } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlError.cs b/identity-server/src/IdentityServer/Saml/Models/SamlError.cs new file mode 100644 index 000000000..5c04c52bb --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlError.cs @@ -0,0 +1,12 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +namespace Duende.IdentityServer.Saml.Models; + +public record SamlError +{ + public required string StatusCode { get; init; } + public string? SubStatusCode { get; init; } + public required string Message { get; init; } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponse.cs b/identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponse.cs new file mode 100644 index 000000000..bc12d73de --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponse.cs @@ -0,0 +1,40 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Diagnostics.CodeAnalysis; + +namespace Duende.IdentityServer.Saml.Models; + +public record SamlInteractionResponse +{ + [MemberNotNullWhen(true, nameof(Error))] + public bool IsError => ResultType == SamlInteractionResponseType.Error; + + public SamlInteractionResponseType ResultType { get; init; } + + public SamlError? Error { get; init; } + + public static SamlInteractionResponse CreateError(string statusCode, string errorDescription) => new SamlInteractionResponse() + { + ResultType = SamlInteractionResponseType.Error, + Error = new SamlError + { + StatusCode = statusCode, + Message = errorDescription + } + }; + + public static SamlInteractionResponse Create(SamlInteractionResponseType type) + { + if (type == SamlInteractionResponseType.Error) + { + throw new InvalidOperationException("Cannot create error interaction response without error details"); + } + + return new SamlInteractionResponse() + { + ResultType = type + }; + } +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponseType.cs b/identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponseType.cs new file mode 100644 index 000000000..c2e1588e2 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlInteractionResponseType.cs @@ -0,0 +1,13 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Saml.Models; + +public enum SamlInteractionResponseType +{ + Login, + AlreadyAuthenticated, + CreateAccount, + Consent, + Error +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlSpSessionData.cs b/identity-server/src/IdentityServer/Saml/Models/SamlSpSessionData.cs new file mode 100644 index 000000000..831b86501 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlSpSessionData.cs @@ -0,0 +1,64 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Represents SAML SP session data stored in the user's authentication session. +/// +/// +/// +/// IMPORTANT: For production deployments with multiple SAML service providers, +/// server-side sessions SHOULD be enabled to avoid cookie size limitations. +/// Configure with: builder.AddServerSideSessions() +/// +/// +/// Without server-side sessions, session data is stored in the authentication cookie. +/// Practical limits are approximately: +/// - 5-8 SAML SPs with 5 OIDC clients +/// - 3-5 SAML SPs with 10+ OIDC clients +/// Browser cookie size limit is ~4KB; exceeding this causes cookie chunking and performance degradation. +/// +/// +/// With server-side sessions enabled, there is no practical limit on the number of SAML sessions. +/// +/// +public class SamlSpSessionData +{ + /// + /// Gets or sets the SAML Service Provider's EntityId. + /// + public string EntityId { get; set; } = default!; + + /// + /// Gets or sets the SAML SessionIndex value for this SP session. + /// This value is unique per SP and is included in the SAML AuthnStatement. + /// + public string SessionIndex { get; set; } = default!; + + /// + /// Gets or sets the NameID value sent to the SP. + /// + public string NameId { get; set; } = default!; + + /// + /// Gets or sets the NameID Format used for this SP. + /// + public string? NameIdFormat { get; set; } + + /// + /// Determines whether the specified object is equal to the current object. + /// Two SamlSpSessionData instances are considered equal if they have the same EntityId and SessionIndex, + /// as these uniquely identify a SAML session at a specific Service Provider. + /// + public override bool Equals(object? obj) => obj is SamlSpSessionData other && + EntityId == other.EntityId && + SessionIndex == other.SessionIndex; + + /// + /// Returns a hash code for this instance based on EntityId and SessionIndex. + /// + public override int GetHashCode() => HashCode.Combine(EntityId, SessionIndex); +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlStatusCodes.cs b/identity-server/src/IdentityServer/Saml/Models/SamlStatusCodes.cs new file mode 100644 index 000000000..150627e87 --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlStatusCodes.cs @@ -0,0 +1,43 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Well-known SAML 2.0 status code URNs as defined in the SAML 2.0 Core specification. +/// +public static class SamlStatusCodes +{ + /// The request succeeded. + public const string Success = "urn:oasis:names:tc:SAML:2.0:status:Success"; + + /// The request could not be performed due to an error on the part of the requester. + public const string Requester = "urn:oasis:names:tc:SAML:2.0:status:Requester"; + + /// The request could not be performed due to an error on the part of the SAML responder or SAML authority. + public const string Responder = "urn:oasis:names:tc:SAML:2.0:status:Responder"; + + /// The SAML responder could not process the request because the version of the request message was incorrect. + public const string VersionMismatch = "urn:oasis:names:tc:SAML:2.0:status:VersionMismatch"; + + /// The responding provider cannot authenticate the principal by means of the currently deployed authentication authority. + public const string NoAuthnContext = "urn:oasis:names:tc:SAML:2.0:status:NoAuthnContext"; + + /// The authentication attempt failed. + public const string AuthnFailed = "urn:oasis:names:tc:SAML:2.0:status:AuthnFailed"; + + /// The responding provider cannot permit a subject confirmation based on the requirements of the requester. + public const string InvalidNameIdPolicy = "urn:oasis:names:tc:SAML:2.0:status:InvalidNameIDPolicy"; + + /// The SAML responder or SAML authority is able to process the request but has chosen not to respond. + public const string RequestDenied = "urn:oasis:names:tc:SAML:2.0:status:RequestDenied"; + + /// The responding provider does not recognize the principal specified or implied by the request. + public const string UnknownPrincipal = "urn:oasis:names:tc:SAML:2.0:status:UnknownPrincipal"; + + /// The SAML responder cannot properly fulfill the request using the protocol binding specified in the request. + public const string UnsupportedBinding = "urn:oasis:names:tc:SAML:2.0:status:UnsupportedBinding"; + + /// The identity provider cannot authenticate the presenter in a manner that satisfies the IsPassive constraint of the request. + public const string NoPassive = "urn:oasis:names:tc:SAML:2.0:status:NoPassive"; +} diff --git a/identity-server/src/IdentityServer/Saml/Models/SamlVersions.cs b/identity-server/src/IdentityServer/Saml/Models/SamlVersions.cs new file mode 100644 index 000000000..df82f769c --- /dev/null +++ b/identity-server/src/IdentityServer/Saml/Models/SamlVersions.cs @@ -0,0 +1,13 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.IdentityServer.Saml.Models; + +/// +/// Well-known SAML version strings. +/// +public static class SamlVersions +{ + /// SAML version 2.0. + public const string V2 = "2.0"; +} diff --git a/identity-server/src/IdentityServer/Services/Default/DefaultIdentityServerInteractionService.cs b/identity-server/src/IdentityServer/Services/Default/DefaultIdentityServerInteractionService.cs index ccea3bdc2..31bca16ea 100644 --- a/identity-server/src/IdentityServer/Services/Default/DefaultIdentityServerInteractionService.cs +++ b/identity-server/src/IdentityServer/Services/Default/DefaultIdentityServerInteractionService.cs @@ -82,14 +82,16 @@ internal class DefaultIdentityServerInteractionService : IIdentityServerInteract if (user != null) { var clientIds = await _userSession.GetClientListAsync(ct); - if (clientIds.Any()) + var samlSessions = await _userSession.GetSamlSessionListAsync(ct); + if (clientIds.Any() || samlSessions.Any()) { var sid = await _userSession.GetSessionIdAsync(ct); var msg = new Message(new LogoutMessage { SubjectId = user.GetSubjectId(), SessionId = sid, - ClientIds = clientIds + ClientIds = clientIds, + SamlSessions = samlSessions }, _timeProvider.GetUtcNow().UtcDateTime); var id = await _logoutMessageStore.WriteAsync(msg, ct); return id; diff --git a/identity-server/src/IdentityServer/Services/Default/DefaultUserSession.cs b/identity-server/src/IdentityServer/Services/Default/DefaultUserSession.cs index e57e1b81a..34e577641 100644 --- a/identity-server/src/IdentityServer/Services/Default/DefaultUserSession.cs +++ b/identity-server/src/IdentityServer/Services/Default/DefaultUserSession.cs @@ -6,6 +6,7 @@ using System.Security.Claims; using Duende.IdentityModel; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Saml.Models; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -223,7 +224,7 @@ public class DefaultUserSession : IUserSession /// /// Ensures the session identifier cookie is synchronized with the current /// session identifier. If there is no sid, the cookie is removed. If there - /// is a sid, and the session identifier cookie is missing, it is issued. + /// is a sid, and the session identifier cookie is missing, it is issued. /// /// The cancellation token. /// @@ -361,4 +362,50 @@ public class DefaultUserSession : IUserSession var scheme = await HttpContext.GetCookieAuthenticationSchemeAsync(); await HttpContext.SignInAsync(scheme, Principal, Properties); } + + /// + public virtual async Task AddSamlSessionAsync(SamlSpSessionData session, Ct ct) + { + ArgumentNullException.ThrowIfNull(session); + + await AuthenticateAsync(); + if (Properties != null) + { + Properties.AddSamlSession(session); + await UpdateSessionCookie(); + } + } + + /// + public virtual async Task> GetSamlSessionListAsync(Ct ct) + { + await AuthenticateAsync(); + + if (Properties != null) + { + try + { + return Properties.GetSamlSessionList(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error getting SAML session list"); + } + } + + return Array.Empty(); + } + + /// + public virtual async Task RemoveSamlSessionAsync(string entityId, Ct ct) + { + ArgumentNullException.ThrowIfNull(entityId); + + await AuthenticateAsync(); + if (Properties != null) + { + Properties.RemoveSamlSession(entityId); + await UpdateSessionCookie(); + } + } } diff --git a/identity-server/src/IdentityServer/Services/IUserSession.cs b/identity-server/src/IdentityServer/Services/IUserSession.cs index ad88e4877..c785dc836 100644 --- a/identity-server/src/IdentityServer/Services/IUserSession.cs +++ b/identity-server/src/IdentityServer/Services/IUserSession.cs @@ -5,6 +5,7 @@ #nullable enable using System.Security.Claims; +using Duende.IdentityServer.Saml.Models; using Microsoft.AspNetCore.Authentication; namespace Duende.IdentityServer.Services; @@ -62,4 +63,29 @@ public interface IUserSession /// The cancellation token. /// Task> GetClientListAsync(Ct ct); + + /// + /// Adds a SAML SP session to the user's session. + /// + /// The SAML session data. + /// The cancellation token. + /// + /// Session data is stored in AuthenticationProperties. For deployments with many SAML service providers, + /// server-side sessions should be enabled to avoid cookie size limitations. + /// See for details. + /// + Task AddSamlSessionAsync(SamlSpSessionData session, Ct ct); + + /// + /// Gets the list of SAML SP sessions for the user's session. + /// + /// The cancellation token. + Task> GetSamlSessionListAsync(Ct ct); + + /// + /// Removes a SAML SP session by EntityId. + /// + /// The SP's entity ID. + /// The cancellation token. + Task RemoveSamlSessionAsync(string entityId, Ct ct); } diff --git a/identity-server/src/IdentityServer/Stores/InMemory/InMemorySamlServiceProviderStore.cs b/identity-server/src/IdentityServer/Stores/InMemory/InMemorySamlServiceProviderStore.cs new file mode 100644 index 000000000..d5ce470e3 --- /dev/null +++ b/identity-server/src/IdentityServer/Stores/InMemory/InMemorySamlServiceProviderStore.cs @@ -0,0 +1,47 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Stores; + +/// +/// In-memory SAML Service Provider store. +/// +public class InMemorySamlServiceProviderStore : ISamlServiceProviderStore +{ + private readonly IEnumerable _serviceProviders; + + /// + /// Initializes a new instance of the class. + /// + /// The service providers. + public InMemorySamlServiceProviderStore(IEnumerable serviceProviders) + { + if (serviceProviders.HasDuplicates(m => m.EntityId)) + { + throw new ArgumentException("Service providers must not contain duplicate entity IDs"); + } + _serviceProviders = serviceProviders; + } + + /// + /// Finds a SAML Service Provider by its entity identifier. + /// + /// The entity identifier of the Service Provider. + /// The cancellation token. + /// The Service Provider, or null if not found. + public Task FindByEntityIdAsync(string entityId, Ct ct) + { + using var activity = Tracing.StoreActivitySource.StartActivity("InMemorySamlServiceProviderStore.FindByEntityId"); + activity?.SetTag(Tracing.Properties.SamlEntityId, entityId); + + var query = + from sp in _serviceProviders + where sp.EntityId == entityId && sp.Enabled + select sp; + + return Task.FromResult(query.SingleOrDefault()); + } +} diff --git a/identity-server/src/IdentityServer/Validation/Default/EndSessionRequestValidator.cs b/identity-server/src/IdentityServer/Validation/Default/EndSessionRequestValidator.cs index 6b8e10e06..f00febfe0 100644 --- a/identity-server/src/IdentityServer/Validation/Default/EndSessionRequestValidator.cs +++ b/identity-server/src/IdentityServer/Validation/Default/EndSessionRequestValidator.cs @@ -9,6 +9,7 @@ using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Logging.Models; using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; using Duende.IdentityServer.Services; using Duende.IdentityServer.Stores; using Microsoft.Extensions.Logging; @@ -50,6 +51,11 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator /// public ILogoutNotificationService LogoutNotificationService { get; } + /// + /// The SAML logout notification service. + /// + protected ISamlLogoutNotificationService SamlLogoutNotificationService { get; } + /// /// The end session message store. /// @@ -63,6 +69,7 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator /// /// /// + /// /// /// public EndSessionRequestValidator( @@ -71,6 +78,7 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator IRedirectUriValidator uriValidator, IUserSession userSession, ILogoutNotificationService logoutNotificationService, + ISamlLogoutNotificationService samlLogoutNotificationService, IMessageStore endSessionMessageStore, ILogger logger) { @@ -79,6 +87,7 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator UriValidator = uriValidator; UserSession = userSession; LogoutNotificationService = logoutNotificationService; + SamlLogoutNotificationService = samlLogoutNotificationService; EndSessionMessageStore = endSessionMessageStore; Logger = logger; } @@ -140,6 +149,9 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator validatedRequest.Subject = subject; validatedRequest.SessionId = await UserSession.GetSessionIdAsync(ct); validatedRequest.ClientIds = await UserSession.GetClientListAsync(ct); + + var samlSessions = await UserSession.GetSamlSessionListAsync(ct); + validatedRequest.SamlSessions = samlSessions; } var redirectUri = parameters.Get(OidcConstants.EndSessionRequest.PostLogoutRedirectUri); @@ -170,6 +182,9 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator validatedRequest.Subject = subject; validatedRequest.SessionId = await UserSession.GetSessionIdAsync(ct); validatedRequest.ClientIds = await UserSession.GetClientListAsync(ct); + + var samlSessions = await UserSession.GetSamlSessionListAsync(ct); + validatedRequest.SamlSessions = samlSessions; } LogSuccess(validatedRequest); @@ -231,10 +246,13 @@ public class EndSessionRequestValidator : IEndSessionRequestValidator var endSessionId = parameters[Constants.UIConstants.DefaultRoutePathParams.EndSessionCallback]; var endSessionMessage = await EndSessionMessageStore.ReadAsync(endSessionId, ct); - if (endSessionMessage?.Data?.ClientIds?.Any() == true) + if (endSessionMessage?.Data?.ClientIds?.Any() == true || endSessionMessage?.Data?.SamlSessions?.Any() == true) { result.IsError = false; result.FrontChannelLogoutUrls = await LogoutNotificationService.GetFrontChannelLogoutNotificationsUrlsAsync(endSessionMessage.Data, ct); + + var samlFrontChannelLogouts = await SamlLogoutNotificationService.GetSamlFrontChannelLogoutsAsync(endSessionMessage.Data, ct); + result.SamlFrontChannelLogouts = samlFrontChannelLogouts; } else { diff --git a/identity-server/src/IdentityServer/Validation/Models/EndSessionCallbackValidationResult.cs b/identity-server/src/IdentityServer/Validation/Models/EndSessionCallbackValidationResult.cs index 4c72b6ca6..fad61ed62 100644 --- a/identity-server/src/IdentityServer/Validation/Models/EndSessionCallbackValidationResult.cs +++ b/identity-server/src/IdentityServer/Validation/Models/EndSessionCallbackValidationResult.cs @@ -4,6 +4,8 @@ #nullable enable +using Duende.IdentityServer.Saml; + namespace Duende.IdentityServer.Validation; /// @@ -16,4 +18,9 @@ public class EndSessionCallbackValidationResult : ValidationResult /// Gets the client front-channel logout urls. /// public IEnumerable? FrontChannelLogoutUrls { get; set; } + + /// + /// Gets or sets the SAML front-channel logout requests. + /// + public IEnumerable SamlFrontChannelLogouts { get; set; } = []; } diff --git a/identity-server/src/IdentityServer/Validation/Models/ValidatedEndSessionRequest.cs b/identity-server/src/IdentityServer/Validation/Models/ValidatedEndSessionRequest.cs index 99e7ab0d0..9bfa8521a 100644 --- a/identity-server/src/IdentityServer/Validation/Models/ValidatedEndSessionRequest.cs +++ b/identity-server/src/IdentityServer/Validation/Models/ValidatedEndSessionRequest.cs @@ -2,6 +2,8 @@ // See LICENSE in the project root for license information. +using Duende.IdentityServer.Saml.Models; + namespace Duende.IdentityServer.Validation; /// @@ -45,4 +47,10 @@ public class ValidatedEndSessionRequest : ValidatedRequest /// Ids of clients known to have an authentication session for user at end session time /// public IEnumerable ClientIds { get; set; } + + /// + /// SAML Service Provider sessions for the user at end session time. + /// Contains full session data including EntityIds, NameIds, and SessionIndexes required for logout notifications. + /// + public IEnumerable SamlSessions { get; set; } = []; } diff --git a/identity-server/src/Shared/Telemetry/Tracing.cs b/identity-server/src/Shared/Telemetry/Tracing.cs index 41e9ca6ae..eb9c295b3 100644 --- a/identity-server/src/Shared/Telemetry/Tracing.cs +++ b/identity-server/src/Shared/Telemetry/Tracing.cs @@ -109,5 +109,6 @@ public static class Tracing public const string ScopeNames = "scope_names"; public const string ApiResourceNames = "api_resource_names"; + public const string SamlEntityId = "saml_entity_id"; } } diff --git a/identity-server/src/Storage/Models/SamlBinding.cs b/identity-server/src/Storage/Models/SamlBinding.cs new file mode 100644 index 000000000..4492d28b7 --- /dev/null +++ b/identity-server/src/Storage/Models/SamlBinding.cs @@ -0,0 +1,22 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +namespace Duende.IdentityServer.Models; + +/// +/// Represents the available SAML protocol bindings for message transport. +/// +public enum SamlBinding +{ + /// + /// HTTP-Redirect binding. + /// + HttpRedirect, + + /// + /// HTTP-POST binding. + /// + HttpPost +} diff --git a/identity-server/src/Storage/Models/SamlEndpointType.cs b/identity-server/src/Storage/Models/SamlEndpointType.cs new file mode 100644 index 000000000..47a16cbe2 --- /dev/null +++ b/identity-server/src/Storage/Models/SamlEndpointType.cs @@ -0,0 +1,22 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +namespace Duende.IdentityServer.Models; + +/// +/// Represents a SAML endpoint with location and binding. +/// +public class SamlEndpointType +{ + /// + /// Gets or sets the URL of the endpoint. + /// + public Uri Location { get; set; } = default!; + + /// + /// Gets or sets the SAML binding used by the endpoint. + /// + public SamlBinding Binding { get; set; } +} diff --git a/identity-server/src/Storage/Models/SamlServiceProvider.cs b/identity-server/src/Storage/Models/SamlServiceProvider.cs new file mode 100644 index 000000000..c49d1ded1 --- /dev/null +++ b/identity-server/src/Storage/Models/SamlServiceProvider.cs @@ -0,0 +1,127 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using System.Security.Cryptography.X509Certificates; + +namespace Duende.IdentityServer.Models; + +/// +/// Models a SAML 2.0 Service Provider configuration. +/// +public class SamlServiceProvider +{ + /// + /// Gets or sets the entity identifier for the Service Provider. + /// This is typically a URI that uniquely identifies the SP. + /// + public string EntityId { get; set; } = default!; + + /// + /// Gets or sets the display name for the Service Provider. + /// Used for logging and consent screens. + /// + public string DisplayName { get; set; } = default!; + + /// + /// Gets or sets the description of the Service Provider. + /// + public string? Description { get; set; } + + /// + /// Gets or sets whether this Service Provider is enabled. + /// Defaults to true. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the clock skew tolerance for validating SAML messages. + /// If null, the global default from SamlOptions.DefaultClockSkew is used. + /// + public TimeSpan? ClockSkew { get; set; } + + /// + /// Gets or sets the maximum age for SAML authentication requests. + /// If null, the global default from SamlOptions.DefaultRequestMaxAge is used. + /// + public TimeSpan? RequestMaxAge { get; set; } + + /// + /// Gets or sets the Assertion Consumer Service (ACS) URLs where SAML responses can be sent. + /// At least one URL is required. + /// + public ICollection AssertionConsumerServiceUrls { get; set; } = new HashSet(); + + /// + /// Gets or sets the SAML binding used for the Assertion Consumer Service. + /// + public SamlBinding AssertionConsumerServiceBinding { get; set; } + + /// + /// Gets or sets the Single Logout Service endpoint where LogoutRequest and LogoutResponse messages should be sent. + /// This is the endpoint at the SP that handles SAML Single Logout protocol messages. + /// + public SamlEndpointType? SingleLogoutServiceUrl { get; set; } + + /// + /// Gets or sets whether the SP's AuthnRequests must be signed. + /// + public bool RequireSignedAuthnRequests { get; set; } + + /// + /// Gets or sets the X.509 certificates used by the SP to sign messages. + /// + public ICollection? SigningCertificates { get; set; } + + /// + /// Gets or sets the X.509 certificates used to encrypt SAML assertions for the SP. + /// + public ICollection? EncryptionCertificates { get; set; } + + /// + /// Gets or sets whether SAML assertions should be encrypted for this SP. + /// + public bool EncryptAssertions { get; set; } + + /// + /// Gets or sets whether consent is required for this SP. + /// + public bool RequireConsent { get; set; } + + /// + /// Gets or sets whether IdP-initiated SSO is allowed for this service provider. + /// When false, IdP-initiated SSO requests will be rejected. + /// Defaults to false (secure by default). + /// + public bool AllowIdpInitiated { get; set; } + + /// + /// Service provider-specific mappings from claim types to SAML attribute names. + /// These mappings override the global DefaultClaimMappings for this service provider. + /// + /// Key: claim type (e.g., "department") + /// Value: SAML attribute name (e.g., "businessUnit") + /// + /// If empty, only global mappings are used. + /// + public IDictionary ClaimMappings { get; set; } = new Dictionary(); + + /// + /// Gets or sets the default NameID format for this SP. + /// If null, the unspecified format is used. + /// + public string? DefaultNameIdFormat { get; set; } = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"; + + /// + /// Gets or sets the claim type used to resolve a persistent name identifier for this SP. + /// Overrides SamlOptions.DefaultPersistentNameIdentifierClaimType. + /// + public string? DefaultPersistentNameIdentifierClaimType { get; set; } + + /// + /// Gets or sets the signing behavior for SAML messages sent to this SP. + /// If null, the global default from SamlOptions.DefaultSigningBehavior is used. + /// + public SamlSigningBehavior? SigningBehavior { get; set; } +} diff --git a/identity-server/src/Storage/Models/SamlSigningBehavior.cs b/identity-server/src/Storage/Models/SamlSigningBehavior.cs new file mode 100644 index 000000000..fff904584 --- /dev/null +++ b/identity-server/src/Storage/Models/SamlSigningBehavior.cs @@ -0,0 +1,38 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +namespace Duende.IdentityServer.Models; + +/// +/// Specifies the signing behavior for SAML messages and assertions. +/// +public enum SamlSigningBehavior +{ + /// + /// Do not sign the SAML Response or Assertion. + /// Only use for testing or non-production scenarios. + /// + DoNotSign = 0, + + /// + /// Sign only the Response element. + /// The signature wraps the entire response including the assertion. + /// + SignResponse = 1, + + /// + /// Sign only the Assertion element (within the Response). + /// This is the most common and recommended strategy. + /// Works with all SAML 2.0 compliant Service Providers. + /// + SignAssertion = 2, + + /// + /// Sign both the Response and the Assertion. + /// Provides maximum security but increases message size. + /// Use for high-security environments. + /// + SignBoth = 3 +} diff --git a/identity-server/src/Storage/Stores/ISamlServiceProviderStore.cs b/identity-server/src/Storage/Stores/ISamlServiceProviderStore.cs new file mode 100644 index 000000000..5e6735297 --- /dev/null +++ b/identity-server/src/Storage/Stores/ISamlServiceProviderStore.cs @@ -0,0 +1,22 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Stores; + +/// +/// Interface for retrieval of SAML Service Provider configuration. +/// +public interface ISamlServiceProviderStore +{ + /// + /// Finds a SAML Service Provider by its entity identifier. + /// + /// The entity identifier of the Service Provider. + /// The cancellation token. + /// The Service Provider, or null if not found. + Task FindByEntityIdAsync(string entityId, Ct ct); +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Common/FakeDistributedCache.cs b/identity-server/test/IdentityServer.IntegrationTests/Common/FakeDistributedCache.cs new file mode 100644 index 000000000..3bc0d899d --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Common/FakeDistributedCache.cs @@ -0,0 +1,62 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Microsoft.Extensions.Caching.Distributed; + +namespace Duende.IdentityServer.IntegrationTests.Common; + +internal class FakeDistributedCache(TimeProvider timeProvider) : IDistributedCache +{ + private readonly Dictionary _items = new(); + + private record CacheEntry(byte[] Value, DateTimeOffset? AbsoluteExpiration); + + public byte[]? Get(string key) + { + if (!_items.TryGetValue(key, out var entry)) + { + return null; + } + + if (!entry.AbsoluteExpiration.HasValue || timeProvider.GetUtcNow() <= entry.AbsoluteExpiration.Value) + { + return entry.Value; + } + + _items.Remove(key); + return null; + } + + public Task GetAsync(string key, CancellationToken token = default) => Task.FromResult(Get(key)); + + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) + { + var absoluteExpiration = options.AbsoluteExpirationRelativeToNow.HasValue + ? timeProvider.GetUtcNow().Add(options.AbsoluteExpirationRelativeToNow.Value) + : options.AbsoluteExpiration; + + _items[key] = new CacheEntry(value, absoluteExpiration); + } + + public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + { + Set(key, value, options); + return Task.CompletedTask; + } + + public void Remove(string key) => _items.Remove(key); + + public Task RemoveAsync(string key, CancellationToken token = default) + { + Remove(key); + return Task.CompletedTask; + } + + public void Refresh(string key) + { + // not currently needed + } + + public Task RefreshAsync(string key, CancellationToken token = default) => Task.CompletedTask; +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/CookieHandler.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/CookieHandler.cs new file mode 100644 index 000000000..180bb4e53 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/CookieHandler.cs @@ -0,0 +1,46 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using System.Net; +using Microsoft.Net.Http.Headers; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +internal class CookieHandler(HttpMessageHandler innerHandler, CookieContainer? cookies = null) : DelegatingHandler(innerHandler) +{ + public void ClearCookies() => CookieContainer = new CookieContainer(); + public CookieContainer CookieContainer { get; private set; } = cookies ?? new CookieContainer(); + + protected override async Task SendAsync(HttpRequestMessage request, Ct ct) + { + var requestUri = request.RequestUri; + var header = CookieContainer.GetCookieHeader(requestUri!); + if (!string.IsNullOrEmpty(header)) + { + request.Headers.Add(HeaderNames.Cookie, header); + } + + var response = await base.SendAsync(request, ct); + + if (response.Headers.TryGetValues(HeaderNames.SetCookie, out var setCookieHeaders)) + { + foreach (var cookieHeader in SetCookieHeaderValue.ParseList(setCookieHeaders.ToList())) + { + var cookie = new Cookie(cookieHeader.Name.Value!, + cookieHeader.Value.Value, + cookieHeader.Path.Value); + if (cookieHeader.Expires.HasValue) + { + cookie.Expires = cookieHeader.Expires.Value.UtcDateTime; + } + + CookieContainer.Add(requestUri!, cookie); + } + + } + + return response; + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlClaimsMappingTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlClaimsMappingTests.cs new file mode 100644 index 000000000..92ca70314 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlClaimsMappingTests.cs @@ -0,0 +1,308 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Collections.ObjectModel; +using System.Net; +using System.Security.Claims; +using Duende.IdentityModel; +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Saml; +using Microsoft.Extensions.DependencyInjection; +using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +public class SamlClaimsMappingTests +{ + private const string Category = "SAML Claims Mapping"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private SamlFixture Fixture = new(); + private SamlDataBuilder Build => Fixture.Builder; + + [Fact] + [Trait("Category", Category)] + public async Task claims_should_use_default_mappings_for_standard_claims() + { + // Arrange - default mappings should be active + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var claims = new List + { + new(JwtClaimTypes.Subject, "user123"), + new("name", "John Doe"), + new("email", "john@example.com"), + new("role", "Admin") + }; + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + + // Verify mapped attributes are present with correct names + var attributes = successResponse.Assertion.Attributes; + attributes.ShouldNotBeNull(); + + var nameAttr = attributes.FirstOrDefault(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"); + nameAttr.ShouldNotBeNull(); + nameAttr.Value.ShouldBe("John Doe"); + + var emailAttr = attributes.FirstOrDefault(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"); + emailAttr.ShouldNotBeNull(); + emailAttr.Value.ShouldBe("john@example.com"); + + var roleAttr = attributes.FirstOrDefault(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/role"); + roleAttr.ShouldNotBeNull(); + roleAttr.Value.ShouldBe("Admin"); + } + + [Fact] + [Trait("Category", Category)] + public async Task unmapped_claims_should_be_excluded_from_assertion() + { + // Arrange - only default mappings active + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var claims = new List + { + new(JwtClaimTypes.Subject, "user123"), + new("name", "John Doe"), + new("custom_claim_not_mapped", "should not appear"), + new("another_unmapped", "also excluded") + }; + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + + var attributes = successResponse.Assertion.Attributes; + attributes.ShouldNotBeNull(); + + // Verify only mapped claim (name) is present + var nameAttr = attributes.FirstOrDefault(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"); + nameAttr.ShouldNotBeNull(); + nameAttr.Value.ShouldBe("John Doe"); + + // Verify unmapped claims are excluded + attributes.ShouldNotContain(a => a.Name != null && a.Name.Contains("custom_claim")); + attributes.ShouldNotContain(a => a.Name != null && a.Name.Contains("another_unmapped")); + } + + [Fact] + [Trait("Category", Category)] + public async Task service_provider_mappings_should_override_global_defaults() + { + // Arrange - SP with custom claim mappings + var spWithCustomMappings = Build.SamlServiceProvider(); + spWithCustomMappings.ClaimMappings = new ReadOnlyDictionary(new Dictionary + { + ["email"] = "mail", // Override default mapping + ["department"] = "ou" // Custom mapping + }); + + Fixture.ServiceProviders.Add(spWithCustomMappings); + await Fixture.InitializeAsync(); + + var claims = new List + { + new(JwtClaimTypes.Subject, "user123"), + new("email", "jane@example.com"), + new("department", "Engineering") + }; + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + + var attributes = successResponse.Assertion.Attributes; + attributes.ShouldNotBeNull(); + + // Verify email uses SP's custom mapping (not default) + var emailAttr = attributes.FirstOrDefault(a => a.Name == "mail"); + emailAttr.ShouldNotBeNull(); + emailAttr.Value.ShouldBe("jane@example.com"); + + // Verify default email mapping is NOT present + attributes.ShouldNotContain(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"); + + // Verify custom department mapping + var deptAttr = attributes.FirstOrDefault(a => a.Name == "ou"); + deptAttr.ShouldNotBeNull(); + deptAttr.Value.ShouldBe("Engineering"); + } + + [Fact] + [Trait("Category", Category)] + public async Task custom_claim_mapper_should_replace_default_mapping_logic() + { + // Arrange - register custom mapper via ConfigureServices + var customMapper = new TestSamlClaimsMapper(); + Fixture.ConfigureServices = services => + { + services.AddSingleton(customMapper); + }; + + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var claims = new List + { + new(JwtClaimTypes.Subject, "user123"), + new("email", "test@example.com"), + new("name", "Should be ignored") + }; + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + + var attributes = successResponse.Assertion.Attributes; + attributes.ShouldNotBeNull(); + + // Verify custom mapper output appears + var customAttr = attributes.FirstOrDefault(a => a.Name == "CUSTOM_MAPPED"); + customAttr.ShouldNotBeNull(); + customAttr.Value.ShouldBe("custom_value"); + + // Verify default mappings were NOT applied (custom mapper replaces everything) + attributes.ShouldNotContain(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"); + attributes.ShouldNotContain(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"); + } + + [Fact] + [Trait("Category", Category)] + public async Task multi_valued_claims_should_be_grouped_into_single_attribute() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var claims = new List + { + new(JwtClaimTypes.Subject, "user123"), + new("role", "Admin"), + new("role", "User"), + new("role", "Manager") + }; + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + + var attributes = successResponse.Assertion.Attributes; + attributes.ShouldNotBeNull(); + + // Verify only one role attribute exists + var roleAttributes = attributes.Where(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/role").ToList(); + roleAttributes.Count.ShouldBe(1); + + // Verify it has all three values + var roleAttr = roleAttributes.First(); + roleAttr.Values.Count.ShouldBe(3); + roleAttr.Values.ShouldContain("Admin"); + roleAttr.Values.ShouldContain("User"); + roleAttr.Values.ShouldContain("Manager"); + } + + [Fact] + [Trait("Category", Category)] + public async Task custom_global_mappings_should_apply_to_all_service_providers() + { + // Arrange - configure custom global mappings via ConfigureServices + Fixture.ConfigureServices = services => + { + // Replace the registered SamlOptions with our custom instance + services.AddSingleton(Microsoft.Extensions.Options.Options.Create(new SamlOptions + { + DefaultClaimMappings = new ReadOnlyDictionary(new Dictionary + { + ["email"] = "emailAddress", + ["department"] = "dept" + }) + })); + }; + + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var claims = new List + { + new(JwtClaimTypes.Subject, "user123"), + new("email", "test@example.com"), + new("department", "Sales") + }; + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + + var attributes = successResponse.Assertion.Attributes; + attributes.ShouldNotBeNull(); + + // Verify custom mappings are used + var emailAttr = attributes.FirstOrDefault(a => a.Name == "emailAddress"); + emailAttr.ShouldNotBeNull(); + emailAttr.Value.ShouldBe("test@example.com"); + + var deptAttr = attributes.FirstOrDefault(a => a.Name == "dept"); + deptAttr.ShouldNotBeNull(); + deptAttr.Value.ShouldBe("Sales"); + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlData.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlData.cs new file mode 100644 index 000000000..8dfcb3745 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlData.cs @@ -0,0 +1,26 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using Microsoft.Extensions.Time.Testing; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +internal class SamlData +{ + public DateTimeOffset Now => FakeTimeProvider.GetUtcNow(); + + public FakeTimeProvider FakeTimeProvider = + new FakeTimeProvider(new DateTimeOffset(2000, 1, 2, 3, 4, 5, TimeSpan.Zero)); + + public string EntityId = "https://sp.example.com"; + + public Uri AcsUrl = new Uri("https://sp.example.com/callback"); + + public Uri SingleLogoutServiceUrl = new Uri("https://sp.example.com/logout"); + + public string RequestId = "_request-123"; + + public string? RelayState = "some_state"; +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlDataBuilder.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlDataBuilder.cs new file mode 100644 index 000000000..1f74c1bba --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlDataBuilder.cs @@ -0,0 +1,140 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using Duende.IdentityServer.Internal.Saml; +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +internal class SamlDataBuilder(SamlData data) +{ + public SamlServiceProvider SamlServiceProvider( + System.Security.Cryptography.X509Certificates.X509Certificate2? signingCertificate = null, + bool requireSignedAuthnRequests = false, + System.Security.Cryptography.X509Certificates.X509Certificate2[]? encryptionCertificates = null, + bool encryptAssertions = false, + string? entityId = null) => new SamlServiceProvider + { + EntityId = entityId ?? data.EntityId, + DisplayName = "Example SP", + Description = "Example SP", + Enabled = true, + RequireSignedAuthnRequests = requireSignedAuthnRequests || signingCertificate != null, + SigningCertificates = signingCertificate != null ? [signingCertificate] : null, + EncryptionCertificates = encryptionCertificates, + EncryptAssertions = encryptAssertions, + AssertionConsumerServiceUrls = [data.AcsUrl], + AssertionConsumerServiceBinding = SamlBinding.HttpPost, + SingleLogoutServiceUrl = new SamlEndpointType { Location = data.SingleLogoutServiceUrl, Binding = SamlBinding.HttpRedirect }, + RequestMaxAge = TimeSpan.FromMinutes(5), + ClockSkew = TimeSpan.FromMinutes(5) + }; + + + public string AuthNRequestXml( + DateTimeOffset? issueInstant = null, + Uri? destination = null, + Uri? acsUrl = null, + int? acsIndex = null, + string? version = null, + bool forceAuthn = false, + bool isPassive = false, + string? requestId = null, + string? issuer = null, + string? requestedAuthnContext = null, + string? nameIdFormat = null, + string? spNameQualifier = null + ) + { + var id = requestId ?? data.RequestId; + var issuerValue = issuer ?? data.EntityId; + + var acsAttributes = ""; + if (acsUrl != null && acsIndex != null) + { + acsAttributes = $"""AssertionConsumerServiceURL="{acsUrl}" AssertionConsumerServiceIndex="{acsIndex}" """; + } + else if (acsUrl != null) + { + acsAttributes = $"""AssertionConsumerServiceURL="{acsUrl}" """; + } + else if (acsIndex != null) + { + acsAttributes = $"""AssertionConsumerServiceIndex="{acsIndex}" """; + } + else + { + acsAttributes = $"""AssertionConsumerServiceURL="{data.AcsUrl}" """; + } + + var nameIdPolicyElement = ""; + if (nameIdFormat != null || spNameQualifier != null) + { + var formatAttr = nameIdFormat != null ? $"""Format="{nameIdFormat}" """ : ""; + var spNameQualifierAttr = spNameQualifier != null ? $"""SPNameQualifier="{spNameQualifier}" """ : ""; + nameIdPolicyElement = $""""""; + } + + return $""" + + + {issuerValue} + {requestedAuthnContext} + {nameIdPolicyElement} + + """.Trim(); + } + + public string LogoutRequestXml( + DateTimeOffset? issueInstant = null, + Uri? destination = null, + string? version = null, + string? requestId = null, + string? issuer = null, + string? nameId = null, + string? nameIdFormat = null, + string? sessionIndex = "12345", + DateTimeOffset? notOnOrAfter = null) + { + var id = requestId ?? data.RequestId; + var issuerValue = issuer ?? data.EntityId; + var nameIdValue = nameId ?? "user@example.com"; + var nameIdFormatValue = nameIdFormat ?? SamlConstants.NameIdentifierFormats.EmailAddress; + + var sessionIndexElement = sessionIndex != null + ? $"{sessionIndex}" + : ""; + + var notOnOrAfterAttr = notOnOrAfter.HasValue + ? $"NotOnOrAfter=\"{notOnOrAfter.Value:yyyy-MM-ddTHH:mm:ssZ}\"" + : ""; + + return $""" + + + {issuerValue} + {nameIdValue} + {sessionIndexElement} + + """.Trim(); + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlEncryptionTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlEncryptionTests.cs new file mode 100644 index 000000000..d9ea8608c --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlEncryptionTests.cs @@ -0,0 +1,461 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Collections.ObjectModel; +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Duende.IdentityModel; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +public class SamlEncryptionTests +{ + private const string Category = "SAML Encryption"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private SamlFixture Fixture = new(); + private SamlData Data => Fixture.Data; + private SamlDataBuilder Build => Fixture.Builder; + + private X509Certificate2 CreateTestEncryptionCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Test SP Encryption", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DataEncipherment, + critical: true)); + + var cert = request.CreateSelfSigned( + Data.Now.AddDays(-7), + Data.Now.AddDays(365)); + + var exported = cert.Export(X509ContentType.Pfx, "test"); + return X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable); + } + + [Fact] + [Trait("Category", Category)] + public async Task successful_auth_should_return_encrypted_assertion() + { + // Arrange + var encryptionCert = CreateTestEncryptionCertificate(); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider( + encryptionCertificates: [encryptionCert], + encryptAssertions: true)); + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(JwtClaimTypes.Subject, "user-123")], + "Test")); + + await Fixture.InitializeAsync(); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Act + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), _ct); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var responseData = await ExtractSamlResponse(result, _ct); + var responseXml = responseData.responseXml; + + // Verify encrypted assertion is present + HasEncryptedAssertion(responseXml).ShouldBeTrue("Response should contain EncryptedAssertion"); + HasPlainAssertion(responseXml).ShouldBeFalse("Response should not contain plain Assertion"); + + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + ValidateEncryptedStructure(responseElement); + } + + [Fact] + [Trait("Category", Category)] + public async Task encrypted_assertion_should_be_decryptable_and_content_verified() + { + // Arrange + var encryptionCert = CreateTestEncryptionCertificate(); + var sp = Build.SamlServiceProvider(encryptionCertificates: [encryptionCert], encryptAssertions: true); + sp.ClaimMappings = new ReadOnlyDictionary(new Dictionary + { + [JwtClaimTypes.Subject] = JwtClaimTypes.Subject, + [JwtClaimTypes.Email] = JwtClaimTypes.Email, + [JwtClaimTypes.Name] = JwtClaimTypes.Name, + [JwtClaimTypes.Role] = JwtClaimTypes.Role + }); + Fixture.ServiceProviders.Add(sp); + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(JwtClaimTypes.Subject, "user-decrypt-test"), + new Claim(JwtClaimTypes.Email, "decrypt@example.com"), + new Claim(JwtClaimTypes.Name, "Decrypt Test User"), + new Claim(JwtClaimTypes.Role, "admin") + ], + "Test")); + + await Fixture.InitializeAsync(); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Act + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), _ct); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - Decrypt and verify actual content + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var samlResponse = await ExtractAndDecryptSamlSuccessFromPostAsync(result, encryptionCert, _ct); + + samlResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + samlResponse.Assertion.ShouldNotBeNull(); + + samlResponse.Assertion.Subject.ShouldNotBeNull(); + samlResponse.Assertion.Subject.NameId.ShouldBe("user-decrypt-test"); + + var attributes = samlResponse.Assertion.Attributes; + attributes.ShouldNotBeNull(); + attributes.Count.ShouldBeGreaterThan(0); + + var subjectAttr = attributes.FirstOrDefault(a => a.Name == JwtClaimTypes.Subject); + subjectAttr.ShouldNotBeNull(); + subjectAttr.Value.ShouldBe("user-decrypt-test"); + + var emailAttr = attributes.FirstOrDefault(a => a.Name == JwtClaimTypes.Email); + emailAttr.ShouldNotBeNull(); + emailAttr.Value.ShouldBe("decrypt@example.com"); + + var nameAttr = attributes.FirstOrDefault(a => a.Name == JwtClaimTypes.Name); + nameAttr.ShouldNotBeNull(); + nameAttr.Value.ShouldBe("Decrypt Test User"); + + var roleAttr = attributes.FirstOrDefault(a => a.Name == JwtClaimTypes.Role); + roleAttr.ShouldNotBeNull(); + roleAttr.Value.ShouldBe("admin"); + } + + [Fact] + [Trait("Category", Category)] + public async Task encrypted_assertion_should_contain_expected_attributes() + { + // Arrange + var encryptionCert = CreateTestEncryptionCertificate(); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider( + encryptionCertificates: [encryptionCert], + encryptAssertions: true)); + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(JwtClaimTypes.Subject, "user-verify-structure"), + new Claim(JwtClaimTypes.Email, "verify@example.com"), + new Claim(JwtClaimTypes.Name, "Verify Test User"), + new Claim(JwtClaimTypes.Role, "admin") + ], + "Test")); + + await Fixture.InitializeAsync(); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Act + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), _ct); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - Verify encrypted structure + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var responseData = await ExtractSamlResponse(result, _ct); + var responseXml = responseData.responseXml; + + // Verify encryption happened + HasEncryptedAssertion(responseXml).ShouldBeTrue(); + HasPlainAssertion(responseXml).ShouldBeFalse(); + + // Parse and validate structure + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + + // Validate encrypted structure per SAML spec + ValidateEncryptedStructure(responseElement); + + // Verify EncryptedData contains ciphertext + var encNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2001/04/xmlenc#"); + var samlNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:assertion"); + + var encryptedAssertion = responseElement.Element(samlNs + "EncryptedAssertion"); + encryptedAssertion.ShouldNotBeNull(); + + var cipherValue = encryptedAssertion.Descendants(encNs + "CipherValue").FirstOrDefault(); + cipherValue.ShouldNotBeNull(); + cipherValue.Value.ShouldNotBeNullOrWhiteSpace("Encrypted data should contain cipher value"); + + // Verify it's actually encrypted (base64-encoded binary data) + var isBase64 = TryFromBase64String(cipherValue.Value, out var _); + isBase64.ShouldBeTrue("CipherValue should be valid base64"); + } + + private static bool TryFromBase64String(string s, out byte[]? result) + { + try + { + result = Convert.FromBase64String(s); + return true; + } + catch + { + result = null; + return false; + } + } + + [Fact] + [Trait("Category", Category)] + public async Task encrypted_assertion_structure_should_be_valid() + { + // Arrange + var encryptionCert = CreateTestEncryptionCertificate(); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider( + encryptionCertificates: [encryptionCert], + encryptAssertions: true)); + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(JwtClaimTypes.Subject, "user-456"), + new Claim(JwtClaimTypes.Email, "test@example.com"), + new Claim(JwtClaimTypes.Name, "Test User") + ], + "Test")); + + await Fixture.InitializeAsync(); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Act + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), _ct); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - Verify structure is valid (can't test decryption due to helper limitations) + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var responseData = await ExtractSamlResponse(result, _ct); + var responseXml = responseData.responseXml; + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + + HasEncryptedAssertion(responseXml).ShouldBeTrue(); + ValidateEncryptedStructure(responseElement); + } + + [Fact] + [Trait("Category", Category)] + public async Task encryption_should_preserve_response_signature() + { + // Arrange + var encryptionCert = CreateTestEncryptionCertificate(); + var sp = Build.SamlServiceProvider( + encryptionCertificates: [encryptionCert], + encryptAssertions: true); + sp.SigningBehavior = SamlSigningBehavior.SignResponse; + Fixture.ServiceProviders.Add(sp); + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(JwtClaimTypes.Subject, "user-789")], + "Test")); + + await Fixture.InitializeAsync(); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Act + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), _ct); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var responseData = await ExtractSamlResponse(result, _ct); + var responseXml = responseData.responseXml; + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + + // Verify response is signed + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + var signature = responseElement.Element(dsNs + "Signature"); + signature.ShouldNotBeNull("Response should be signed"); + + // Verify encrypted assertion is present + HasEncryptedAssertion(responseXml).ShouldBeTrue(); + } + + [Fact] + [Trait("Category", Category)] + public async Task encryption_should_work_with_sign_assertion_behavior() + { + // Arrange + var encryptionCert = CreateTestEncryptionCertificate(); + var sp = Build.SamlServiceProvider( + encryptionCertificates: [encryptionCert], + encryptAssertions: true); + sp.SigningBehavior = SamlSigningBehavior.SignAssertion; + Fixture.ServiceProviders.Add(sp); + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(JwtClaimTypes.Subject, "user-sign-assertion")], + "Test")); + + await Fixture.InitializeAsync(); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Act + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), _ct); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - Encryption should happen after signing + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var responseData = await ExtractSamlResponse(result, _ct); + HasEncryptedAssertion(responseData.responseXml).ShouldBeTrue(); + } + + [Fact] + [Trait("Category", Category)] + public async Task encryption_should_work_with_sign_both_behavior() + { + // Arrange + var encryptionCert = CreateTestEncryptionCertificate(); + var sp = Build.SamlServiceProvider( + encryptionCertificates: [encryptionCert], + encryptAssertions: true); + sp.SigningBehavior = SamlSigningBehavior.SignBoth; + Fixture.ServiceProviders.Add(sp); + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(JwtClaimTypes.Subject, "user-sign-both")], + "Test")); + + await Fixture.InitializeAsync(); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Act + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), _ct); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var responseData = await ExtractSamlResponse(result, _ct); + var responseXml = responseData.responseXml; + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + + // Verify response is signed + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + var responseSignature = responseElement.Element(dsNs + "Signature"); + responseSignature.ShouldNotBeNull("Response should be signed"); + + // Verify encrypted assertion is present + HasEncryptedAssertion(responseXml).ShouldBeTrue(); + } + + [Fact] + [Trait("Category", Category)] + public async Task multiple_certificates_should_use_first_valid() + { + // Arrange + var validCert1 = CreateTestEncryptionCertificate(); + var validCert2 = CreateTestEncryptionCertificate(); + + Fixture.ServiceProviders.Add(Build.SamlServiceProvider( + encryptionCertificates: [validCert1, validCert2], + encryptAssertions: true)); + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(JwtClaimTypes.Subject, "user-multi-cert")], + "Test")); + + await Fixture.InitializeAsync(); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Act + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), _ct); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - Should encrypt successfully with first valid cert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var responseData = await ExtractSamlResponse(result, _ct); + HasEncryptedAssertion(responseData.responseXml).ShouldBeTrue(); + } + + [Fact] + [Trait("Category", Category)] + public async Task expired_certificate_should_cause_server_error() + { + // Arrange + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Expired Cert", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + var expiredCert = request.CreateSelfSigned( + Data.Now.AddDays(-365), + Data.Now.AddDays(-1)); + + Fixture.ServiceProviders.Add(Build.SamlServiceProvider( + encryptionCertificates: [expiredCert], + encryptAssertions: true)); + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(JwtClaimTypes.Subject, "user-expired")], + "Test")); + + await Fixture.InitializeAsync(); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Act + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), _ct); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - Expired cert is a configuration error, so expect 500 + result.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + [Trait("Category", Category)] + public async Task no_encryption_when_certificates_not_configured() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider( + encryptionCertificates: null)); // No encryption certs + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(JwtClaimTypes.Subject, "user-no-encrypt")], + "Test")); + + await Fixture.InitializeAsync(); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Act + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(), _ct); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - Should return plain assertion + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var responseData = await ExtractSamlResponse(result, _ct); + var responseXml = responseData.responseXml; + + HasPlainAssertion(responseXml).ShouldBeTrue("Response should contain plain Assertion"); + HasEncryptedAssertion(responseXml).ShouldBeFalse("Response should not be encrypted"); + + // Verify can parse as success + var samlResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + samlResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + samlResponse.Assertion.ShouldNotBeNull(); + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlFixture.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlFixture.cs new file mode 100644 index 000000000..cacd07c61 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlFixture.cs @@ -0,0 +1,287 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.IntegrationTests.Common; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +internal class SamlFixture : IAsyncLifetime +{ + private readonly Ct _ct = TestContext.Current.CancellationToken; + + public SamlData Data = new SamlData(); + public SamlDataBuilder Builder => new SamlDataBuilder(Data); + + public const string StableSigningCert = + "MIIJKgIBAzCCCOYGCSqGSIb3DQEHAaCCCNcEggjTMIIIzzCCBZAGCSqGSIb3DQEHAaCCBYEEggV9MIIFeTCCBXUGCyqGSIb3DQEMCgECoIIE7jCCBOowHAYKKoZIhvcNAQwBAzAOBAjDpcH3qgMDswICB9AEggTI6G9c95q92L598SOzgmrJ+Rq8qJDITcRgkRV+2KBCCbmlrrawihxy4O/+T2FHk3xBkPiXuLTcxO3uhl1juEc4PWSZpL2JA41okYMn4O+z3R8I3H8aN6OrL1cYthNfLLC0d46BjSBvKVVo3zyeJxui0hE7wEbNQrqRilRqBZjvemY2Pnb+BbVHIpa6FHQsUG80Cru0Jz6Gm21qM0j+enFHgAhPjlLoIw31ar8QmAPSqwZHiCZujw7RraL5E9Y5sGIZh6JaqTR2+cjRO32mnguFFHZg6s6APeOyPDNGEP16U0tRgnnUqMD0w6cDz3f2GIkzZ6YqfaMXBhzYQYtkxL6OYEU1G9Ke3UTlFwBNP8uMMcK6CKD4oy9Qi7Q4OtCqSJ9RBjKCoRXioA301Uf7iFfKLMBxZzNKczBXUxSv8EFXJ9hbpwmZPzoyqrL6JrSx3r99yJPbMQnPvdJtu+Uuo5WeTDkLlGFcVk+gmF/6vsP8Xb89sdtbHA87zAGgwS9+9huQa1umaAU6ftnUUEj6q2GktMPkGOuBI1JCtKOySKObC89HTC6FzwCJjhxwdUFl7WdY3QgjaWv5/NtG1kivvuuFoyrsVAOe+oWMQ6rxvJzrmilXLjCpE+jlAcoZn21jGzIJ2JMky1Ni5p1zj0XYkSlQ8c6Kh65UX0Bcj1kMFntlAe/N7XtPjF8bI1Q7sRc2ft5OH4oNfmXZgZqqbEHmWsSbVaFFfIhwUDmvXftqj6H+E345a9RibCx98sgQ3Pv9Xb3sRemTXR8juSRmb6P/OWIK2zorxqNvqruVfQ7UcH8I7QLgq/8ai1SClLhyOm3j6eWZim1aRO2wErN+DdcapYMFAu0CVo8ziGR9EIyXsXhjXbEr3EJPf87/g36Xt+LNOzTLKxE7npy39xMKBUh8kIjvroqdkaG3f16QXmUtLzmjPKdEiCCxgg89YRRgOlnxAXx6Kl1FVvHIYmcEqZ5yZ32fhB8X2aydia+JZO6w6MpUbSdULaVi1rmDnPHi2eco2hu83Iv59TRRI5JfeTgZVnxyMEuDI4NEaffLQJpUd3gDKJ3XgMmy8jSaizlh17MXCxM7bUtlNGMSHEM6eCyL5SUJHK9d3q4Qbw/ZPPLqvut6Y41gyKFfLDTU3BanWeh4wbk4YwBnZU71MUFGrAIr/0oTTw+fjd2bf0Utp2quRj/d65WVSZ71L7GqgwWf81A48ztw7eUHeXAvpheKk8Qtm7yPNdXbEfWo1PaVx5upj/P+GLcSfKNiOM7XWFhgCkrXo5hTMee4hahHrWFO8yXIAwBeg1+fvnbkIvna1lSzaqfTkoYg8LFYjaf0H3MQlBku09y8uApdOr2k0yx+NkvuKGPpHhW2MxCrmTfmxN9CaYvUEHYiWNWWN8H2MHQOTMGNXWFGL4jvq3ZWC2Bve4nlGYTE6Xy8MSJFyvnHNzJI13V7ibvCXdWKSSp8RT7IggpULiotlYQltkidWq7LGgYTDJtuhlc6xq/jok+kk2wSLSochaht6IhER9KcIUo7ChWlFcoQhFwWHckVNnLoN7W8uj5Y3b+bjazQqHaeWwE3gN9wrRk8joZcxz1EBaIVpzGy0LgObxwZ2udLExvRHYIcNl+pWSAr46hqOUWnMXQwEwYJKoZIhvcNAQkVMQYEBAEAAAAwXQYJKwYBBAGCNxEBMVAeTgBNAGkAYwByAG8AcwBvAGYAdAAgAFMAbwBmAHQAdwBhAHIAZQAgAEsAZQB5ACAAUwB0AG8AcgBhAGcAZQAgAFAAcgBvAHYAaQBkAGUAcjCCAzcGCSqGSIb3DQEHBqCCAygwggMkAgEAMIIDHQYJKoZIhvcNAQcBMBwGCiqGSIb3DQEMAQMwDgQIqlETdqBe/T4CAgfQgIIC8ExRN8tCI6+rs5ZvRWyeBUfws5GtCIXbOOnIaSimlGUmh9pUJxWLqHyG1U8lZ6FBOSOB8QxUrYxWtVx1868Y7Z6v/YLEmIno1OJOtliuCihaxjmspGaBiuin9F1Lc2GWT45IQVyUd8vRysqpvCQu+0Gb6eShM2XjQvYHF0poMiwLTfg5QjAHwtKfjhyj83B52QQlsdEz9n8YdImyXJJ0vbqu+AnLiNIGY3s1huyQMVoVmBwDlXhDL3FNbfsY90K1TVG0U/DIChgD+wJbyMxlt7O82dMONy/FEXVEFS8N2/JvJKqVYdSz4qFm1Pwn6dLHNseqdLNJrOWAhZmYbvUeiaNoKT6JmEt245x9qQTClsY1/irY4w5vhQWgHbaAtnLbp/Zd67IZPmDD93WPcGL/5e8Ir2yN9RTVpjAtd2cRC9PH5+Cc4EQxbkWSWEpq3/cvGAddkO7JKfAGYHJG98ClwyDR3WrXYZQze8zFeS2S2U5Xg+oryx0DumvhHYdf9OkYr2JO2VJl7KZu33P7v64M74MRcmixMQSfu/zndI5oHj2WYfI+mYZdyqZh8vMbo/c43qNvOA+vjFA8WaN4TzJljfJeh8t5qakUXTGvbwqczIz7ZrqrDGtWKVJoh4EXRS18zMRtUT1UbVYH44Jl5uxB1wUIYLcOeWPKZ6qZ8eCZsWzfR+zfur/gXmA4XQ7jf/tupr+kVpfM4SxqTRuE4T1xjviza0grM41cwmkfVlQPf5LQthIxBJIURxp7reJ1LasIGlXsWqfXk6U9WvcqZSXJNPxrhtPdcGhqbY3AOXxvouhJ7WH5R9LIYm2j1jFFJJAr33t3hoSAOwexBq6AOz8zFOor6SCywRXvnOuDpN0TuZba/iKSZynphnNiPkcDkL4N1hKIedRvFIoqGCYyHy77USF4m5ROQTRX6m8zF7jO4scSBRh49Z1frXlgkJalBhZKGCjg92G+VEZxSc9MJIh8wHST9CmgE84DheKNANASGkLmMDswHzAHBgUrDgMCGgQUpzniHRXBmJOBcoAPTQv0qII5VBoEFM2apPkmEUkwaCpLAq+z0GGqhAGCAgIH0A=="; + + public Action ConfigureIdentityServerOptions = _ => { }; + + public Action ConfigureSamlOptions = _ => { }; + + public Action ConfigureServices = _ => { }; + + private List _serviceProviders = []; + private bool _isInitialized = false; + + /// + /// Gets the list of service providers to seed during initialization. + /// IMPORTANT: This property can only be accessed before InitializeAsync is called. + /// After initialization, use AddServiceProviderAsync or ClearServiceProvidersAsync methods. + /// + public List ServiceProviders + { + get + { + if (_isInitialized) + { + throw new InvalidOperationException( + "Cannot access ServiceProviders after initialization. " + + "Use AddServiceProviderAsync() to add service providers after the fixture has been initialized, " + + "or ClearServiceProvidersAsync() to remove all service providers."); + } + return _serviceProviders; + } + } + + public DateTimeOffset Now => Data.Now; + + public Uri LoginUrl = new Uri("/account/login", UriKind.Relative); + + public Uri ConsentUrl = new Uri("/consent", UriKind.Relative); + + public Uri SignInCallbackUrl = new Uri("/saml/signin_callback", UriKind.Relative); + + public Uri LogoutUrl = new Uri("/account/logout", UriKind.Relative); + + public ClaimsPrincipal? UserToSignIn { get; set; } + + public bool? UserMetRequestedAuthnContextRequirements { get; set; } + + public AuthenticationProperties? PropsToSignIn { get; set; } + + private IdentityServerPipeline _pipeline = null!; + + public IdentityServerPipeline Pipeline => _pipeline; + + public BrowserClient Client { get; private set; } = null!; + + public BrowserClient NonRedirectingClient { get; private set; } = null!; + + public string Url(string path = "") + { + if (!path.StartsWith('/') && !string.IsNullOrEmpty(path)) + { + path = '/' + path; + } + + return IdentityServerPipeline.BaseUrl + path; + } + + public T Get() where T : notnull => _pipeline.Resolve(); + + public async ValueTask InitializeAsync() + { + var selfSignedCertificate = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(StableSigningCert), null); + + _pipeline = new IdentityServerPipeline(); + + _pipeline.OnPreConfigureServices += services => + { + services.AddSingleton(Data.FakeTimeProvider); + services.AddSingleton(sp => new FakeDistributedCache(sp.GetRequiredService())); + services.AddRouting(); + services.AddAuthorization(); + }; + + _pipeline.OnPostConfigureServices += services => + { + // Configure IdentityServer options (pipeline already calls AddIdentityServer) + services.Configure(options => + { + options.UserInteraction.LoginUrl = LoginUrl.ToString(); + options.UserInteraction.LogoutUrl = LogoutUrl.ToString(); + options.UserInteraction.ConsentUrl = ConsentUrl.ToString(); + options.KeyManagement.Enabled = false; // Disable key management to use our custom credential + }); + services.Configure(ConfigureIdentityServerOptions); + + // Configure SAML options + services.Configure(ConfigureSamlOptions); + + // Replace the developer signing credential with our X509 certificate + // Remove the ISigningCredentialStore registration added by AddDeveloperSigningCredential + var signingCredentialDescriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ISigningCredentialStore)); + if (signingCredentialDescriptor != null) + { + services.Remove(signingCredentialDescriptor); + } + + // Add our X509 signing credential + services.AddIdentityServerBuilder() + .AddSigningCredential(selfSignedCertificate) + .AddProfileService() + .AddSaml() + .AddInMemorySamlServiceProviders(_serviceProviders); + + ConfigureServices(services); + + services.AddProblemDetails(opt => opt.CustomizeProblemDetails = context => + { + if (context.Exception is BadHttpRequestException ex) + { + context.HttpContext.Response.StatusCode = 400; + context.ProblemDetails.Detail = ex.Message; + context.ProblemDetails.Status = 400; + } + }); + }; + + _pipeline.OnPreConfigure += app => + { + app.UseExceptionHandler("/error"); + }; + + _pipeline.OnPostConfigure += app => + { + // Error handling endpoint + app.Map("/error", path => + { + path.Run(async context => + { + var exceptionFeature = context.Features.Get(); + if (exceptionFeature?.Error is Microsoft.AspNetCore.Http.BadHttpRequestException badRequestEx) + { + context.Response.StatusCode = badRequestEx.StatusCode; + context.Response.ContentType = "application/problem+json"; + await context.Response.WriteAsJsonAsync(new Microsoft.AspNetCore.Mvc.ProblemDetails + { + Status = badRequestEx.StatusCode, + Title = "Bad Request", + Detail = badRequestEx.Message + }); + } + else + { + context.Response.StatusCode = 500; + await context.Response.WriteAsync("Internal Server Error"); + } + }); + }); + + app.Map(LoginUrl.ToString(), path => + { + path.Run(ctx => + { + ctx.Response.StatusCode = 200; + return Task.CompletedTask; + }); + }); + + app.Map(ConsentUrl.ToString(), path => + { + path.Run(ctx => + { + ctx.Response.StatusCode = 200; + return Task.CompletedTask; + }); + }); + + app.Map(LogoutUrl.ToString(), path => + { + path.Run(ctx => + { + ctx.Response.StatusCode = 200; + return Task.CompletedTask; + }); + }); + + app.Map("/__signin", path => + { + path.Run(async ctx => + { + var samlInteractionService = ctx.RequestServices.GetRequiredService(); + var props = PropsToSignIn ?? new AuthenticationProperties(); + if (UserToSignIn?.Identity == null) + { + throw new InvalidOperationException( + $"Must set {nameof(UserToSignIn)} prior to signin and must have an identity"); + } + + await ctx.SignInAsync(UserToSignIn, props); + + if (UserMetRequestedAuthnContextRequirements.HasValue) + { + await samlInteractionService.StoreRequestedAuthnContextResultAsync( + UserMetRequestedAuthnContextRequirements.Value, ctx.RequestAborted); + } + + ctx.Response.StatusCode = 204; + }); + }); + + app.Map("/__signout", path => + { + path.Run(async ctx => + { + await ctx.SignOutAsync(); + ctx.Response.StatusCode = 204; + }); + }); + + app.Map("/__authentication-request", path => + { + path.Run(async ctx => + { + var samlInteractionService = ctx.RequestServices.GetRequiredService(); + var authenticationRequest = + await samlInteractionService.GetAuthenticationRequestContextAsync(_ct); + + if (authenticationRequest == null) + { + throw new InvalidOperationException("Could not find authentication request"); + } + + await ctx.Response.WriteAsJsonAsync(authenticationRequest.RequestedAuthnContext); + }); + }); + }; + + _pipeline.Initialize(enableLogging: true); + + // Mark as initialized after seeding + _isInitialized = true; + + // Create two BrowserClient instances with different redirect behaviors + Client = _pipeline.BrowserClient; + Client.BaseAddress = new Uri(IdentityServerPipeline.BaseUrl); + + NonRedirectingClient = new BrowserClient(new BrowserHandler(_pipeline.Handler) { AllowAutoRedirect = false }) + { + BaseAddress = new Uri(IdentityServerPipeline.BaseUrl) + }; + } + + public async ValueTask DisposeAsync() => + // IdentityServerPipeline doesn't implement IAsyncDisposable, so nothing to dispose + await Task.CompletedTask; + + /// + /// Removes all service providers from the fixture after initialization. + /// + public void ClearServiceProvidersAsync() => _serviceProviders.Clear(); +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlIdpInitiatedEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlIdpInitiatedEndpointTests.cs new file mode 100644 index 000000000..03f8190fa --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlIdpInitiatedEndpointTests.cs @@ -0,0 +1,387 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Web; +using Duende.IdentityModel; +using Duende.IdentityServer.Internal.Saml; +using Microsoft.AspNetCore.Mvc; +using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +public class SamlIdpInitiatedEndpointTests +{ + private const string Category = "SAML IdP-Initiated Endpoint"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private SamlFixture Fixture = new(); + + private SamlData Data => Fixture.Data; + + private SamlDataBuilder Build => Fixture.Builder; + + [Fact] + [Trait("Category", Category)] + public async Task can_initiate_idp_sso_when_user_is_authenticated() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.AllowIdpInitiated = true; + Fixture.ServiceProviders.Add(sp); + Fixture.ConfigureIdentityServerOptions = options => + { + options.Endpoints.EnableSamlIdpInitiatedEndpoint = true; + }; + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + // Act + var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString()); + var result = await Fixture.NonRedirectingClient.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.Found); + var redirectUri = result.Headers.Location; + redirectUri.ShouldNotBeNull(); + redirectUri.ToString().ShouldBe($"{SamlConstants.Urls.SamlRoute}{SamlConstants.Urls.SigninCallback}"); + } + + [Fact] + [Trait("Category", Category)] + public async Task can_initiate_idp_sso_with_relay_state() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.AllowIdpInitiated = true; + Fixture.ServiceProviders.Add(sp); + Fixture.ConfigureIdentityServerOptions = options => + { + options.Endpoints.EnableSamlIdpInitiatedEndpoint = true; + }; + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + // Act + var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString()); + var relayState = HttpUtility.UrlEncode("/my-app/dashboard"); + var result = await Fixture.NonRedirectingClient.GetAsync( + $"/saml/idp-initiated?spEntityId={spEntityId}&relayState={relayState}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.Found); + var redirectUri = result.Headers.Location; + redirectUri.ShouldNotBeNull(); + redirectUri.ToString().ShouldBe($"{SamlConstants.Urls.SamlRoute}{SamlConstants.Urls.SigninCallback}"); + + var acsResult = await Fixture.NonRedirectingClient.GetAsync(redirectUri, _ct); + + // Assert + var samlResponse = await ExtractSamlSuccessFromPostAsync(acsResult, _ct); + samlResponse.RelayState.ShouldBe(HttpUtility.UrlDecode(relayState)); + } + + [Fact] + [Trait("Category", Category)] + public async Task redirects_to_login_when_user_not_authenticated() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.AllowIdpInitiated = true; + Fixture.ServiceProviders.Add(sp); + Fixture.ConfigureIdentityServerOptions = options => + { + options.Endpoints.EnableSamlIdpInitiatedEndpoint = true; + }; + await Fixture.InitializeAsync(); + + // Act + var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString()); + var result = await Fixture.NonRedirectingClient.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.Found); + var redirectUri = result.Headers.Location; + redirectUri.ShouldNotBeNull(); + HttpUtility.UrlDecode(redirectUri.ToString()).ShouldBe($"{Fixture.LoginUrl}?ReturnUrl={Fixture.SignInCallbackUrl}"); + } + + [Fact] + [Trait("Category", Category)] + public async Task returns_error_when_sp_not_registered() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + Fixture.ConfigureIdentityServerOptions = options => + { + options.Endpoints.EnableSamlIdpInitiatedEndpoint = true; + }; + await Fixture.InitializeAsync(); + + // Act + var unknownEntityId = HttpUtility.UrlEncode("https://unknown.example.com"); + var result = await Fixture.Client.GetAsync($"/saml/idp-initiated?spEntityId={unknownEntityId}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("Service Provider 'https://unknown.example.com' is not registered"); + } + + [Fact] + [Trait("Category", Category)] + public async Task returns_error_when_sp_is_disabled() + { + // Arrange + // Note: The InMemoryServiceProviderStore filters disabled SPs, + // so they appear as "not registered". This is correct behavior. + var sp = Build.SamlServiceProvider(); + sp.Enabled = false; + sp.AllowIdpInitiated = true; + Fixture.ServiceProviders.Add(sp); + Fixture.ConfigureIdentityServerOptions = options => + { + options.Endpoints.EnableSamlIdpInitiatedEndpoint = true; + }; + await Fixture.InitializeAsync(); + + // Act + var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString()); + var result = await Fixture.Client.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + // Disabled SPs are filtered by the store, so they appear as not registered + problemDetails.Detail.ShouldBe($"Service Provider '{Data.EntityId}' is not registered"); + } + + [Fact] + [Trait("Category", Category)] + public async Task returns_error_when_idp_initiated_not_allowed() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.AllowIdpInitiated = false; + Fixture.ServiceProviders.Add(sp); + Fixture.ConfigureIdentityServerOptions = options => + { + options.Endpoints.EnableSamlIdpInitiatedEndpoint = true; + }; + await Fixture.InitializeAsync(); + + // Act + var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString()); + var result = await Fixture.Client.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"Service Provider '{Data.EntityId}' does not allow IdP-initiated SSO"); + } + + [Fact] + [Trait("Category", Category)] + public async Task returns_error_when_relay_state_exceeds_max_length() + { + // Arrange + Fixture.ConfigureSamlOptions = options => + { + options.MaxRelayStateLength = 50; + }; + + var sp = Build.SamlServiceProvider(); + sp.AllowIdpInitiated = true; + Fixture.ServiceProviders.Add(sp); + Fixture.ConfigureIdentityServerOptions = options => + { + options.Endpoints.EnableSamlIdpInitiatedEndpoint = true; + }; + await Fixture.InitializeAsync(); + + // Act + var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString()); + var longRelayState = HttpUtility.UrlEncode(new string('a', 100)); + var result = await Fixture.Client.GetAsync( + $"/saml/idp-initiated?spEntityId={spEntityId}&relayState={longRelayState}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("RelayState exceeds maximum length of 50 bytes"); + } + + [Fact] + [Trait("Category", Category)] + public async Task returns_error_when_sp_has_no_acs_urls() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.AllowIdpInitiated = true; + sp.AssertionConsumerServiceUrls = Array.Empty(); + Fixture.ServiceProviders.Add(sp); + Fixture.ConfigureIdentityServerOptions = options => + { + options.Endpoints.EnableSamlIdpInitiatedEndpoint = true; + }; + await Fixture.InitializeAsync(); + + // Act + var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString()); + var result = await Fixture.Client.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"Service Provider '{Data.EntityId}' has no AssertionConsumerServiceUrls configured"); + } + + [Fact] + [Trait("Category", Category)] + public async Task uses_first_acs_url_when_multiple_configured() + { + // Arrange + var firstAcsUrl = new Uri("https://sp.example.com/acs/first"); + var secondAcsUrl = new Uri("https://sp.example.com/acs/second"); + + var sp = Build.SamlServiceProvider(); + sp.AllowIdpInitiated = true; + sp.AssertionConsumerServiceUrls = [firstAcsUrl, secondAcsUrl]; + Fixture.ServiceProviders.Add(sp); + Fixture.ConfigureIdentityServerOptions = options => + { + options.Endpoints.EnableSamlIdpInitiatedEndpoint = true; + }; + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + // Act + var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString()); + var result = await Fixture.NonRedirectingClient.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.Found); + var redirectUri = result.Headers.Location; + redirectUri.ShouldNotBeNull(); + redirectUri.ToString().ShouldBe($"{SamlConstants.Urls.SamlRoute}{SamlConstants.Urls.SigninCallback}"); + + var acsResult = await Fixture.NonRedirectingClient.GetAsync(redirectUri.ToString(), _ct); + + // Assert + var samlResponse = await ExtractSamlSuccessFromPostAsync(acsResult, _ct); + samlResponse.Destination.ShouldBe(firstAcsUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task can_complete_full_idp_initiated_flow() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.AllowIdpInitiated = true; + Fixture.ServiceProviders.Add(sp); + Fixture.ConfigureIdentityServerOptions = options => + { + options.Endpoints.EnableSamlIdpInitiatedEndpoint = true; + }; + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(JwtClaimTypes.Subject, "user-123"), + new Claim(JwtClaimTypes.Email, "user@example.com"), + new Claim(JwtClaimTypes.Name, "Test User") + ], "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString()); + var relayState = HttpUtility.UrlEncode("/target/page"); + + // Act + // Step 1: Initiate IdP SSO + var initiateResult = await Fixture.NonRedirectingClient.GetAsync( + $"/saml/idp-initiated?spEntityId={spEntityId}&relayState={relayState}", _ct); + + initiateResult.StatusCode.ShouldBe(HttpStatusCode.Found); + initiateResult.Headers.Location.ShouldNotBeNull(); + initiateResult.Headers.Location.ToString().ShouldBe("/saml/signin_callback"); + + var stateId = ExtractStateIdFromCookie(initiateResult); + stateId.ShouldNotBeNull(); + + // Step 2: Follow redirect to signin callback + var callbackResult = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + // Assert + callbackResult.StatusCode.ShouldBe(HttpStatusCode.OK); + + var samlResponse = await ExtractSamlSuccessFromPostAsync(callbackResult, _ct); + + samlResponse.ShouldNotBeNull(); + samlResponse.Issuer.ShouldBe(Fixture.Url()); + samlResponse.Destination.ShouldBe(Data.AcsUrl.ToString()); + samlResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success"); + + samlResponse.InResponseTo.ShouldBeNull(); + + samlResponse.RelayState.ShouldBe("/target/page"); + + samlResponse.Assertion.Subject.ShouldNotBeNull(); + samlResponse.Assertion.Subject.NameId.ShouldNotBeNullOrEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public async Task endpoint_disabled_when_configuration_disables_it() + { + // Arrange + Fixture.ConfigureIdentityServerOptions = options => + { + options.Endpoints.EnableSamlIdpInitiatedEndpoint = false; + }; + + var sp = Build.SamlServiceProvider(); + sp.AllowIdpInitiated = true; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Act + var spEntityId = HttpUtility.UrlEncode(Data.EntityId.ToString()); + var result = await Fixture.Client.GetAsync($"/saml/idp-initiated?spEntityId={spEntityId}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Fact] + [Trait("Category", Category)] + public async Task missing_sp_entity_id_parameter_returns_bad_request() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + Fixture.ConfigureIdentityServerOptions = options => + { + options.Endpoints.EnableSamlIdpInitiatedEndpoint = true; + }; + await Fixture.InitializeAsync(); + + var result = await Fixture.Client.GetAsync("/saml/idp-initiated", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.CanReturnMetadata.verified.txt b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.CanReturnMetadata.verified.txt new file mode 100644 index 000000000..c720b66d0 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.CanReturnMetadata.verified.txt @@ -0,0 +1,20 @@ + + + + + + + MIICojCCAYqgAwIBAgIIIjGqKDo3ME4wDQYJKoZIhvcNAQELBQAwETEPMA0GA1UEAxMGZm9vYmFyMB4XDTI1MTExMDE0MTEyNloXDTMwMTExMDE0MTEyNlowETEPMA0GA1UEAxMGZm9vYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu/fcM55jlB810lyxGpgk0Zhw83Liqz80l3zLLAZgJ/IUdBx9VFD28BeO37eByHDXxBIQdHFYXQj+lv2g3KFRxVzfZhiFUrb1UydJYFZ951sQUEsP4T/Fpbyb95HNrwG2NwE5/fk1MXr9no4ydsQTZA6EWOfbxn6o2YQs/8QdDykhCzpZcWYbk5AKS/G6nYLpwuW4UsyMQ6ur9ZQXtwDS/hGyP3RjK8pjqkckbQG9ZapI+hWezIJkGmkXcuIx+FpZbdjjwu/SIcNNrBIXLbrbWyxoWt4y2jWfDixanBAubBLtx6tCg69trJ3M5gZkFZBR3CVqs78fYZUThKBTS20afQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCxB08tE2bDWpF5mR14kQvRUA/2hZKeC6CYYGEwOu1hbh5m3rVj4T9GPgOh+s6tX+rCb0IoV1uD9iSeTd3XaJ/1sSFkgVD/PaA6NRgzKVeDXLl9rZGAnOmp/Es3Pz35FbPxZKTe8UDyFHySbioLaLvtODhzX7SeGP3BcRpp8rZLvggMYiqo3w39+qZcgZPIBP4yRSulBYb3r9qagQ/n//gp7SmenCQmjA5L7pTn7QggFQsSQmB6dyNS54cUk0niUsTihT9oqpMnXmsXonXf5cv3tnaydreiB4aPea+OjjY3oy8hvHUH6FuQQX7t3RllZlPGJQFZe61rYMVmRRjlHWTA + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + + + + + + diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.cs new file mode 100644 index 000000000..c7e03a10d --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.cs @@ -0,0 +1,233 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +public class SamlMetadataEndpointTests +{ + private const string Category = "SAML Metadata Endpoint"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private SamlFixture Fixture = new(); + + [Fact] + [Trait("Category", Category)] + public async Task metadata_endpoint_should_return_metadata() + { + await Fixture.InitializeAsync(); + + var result = await Fixture.Client.GetAsync("/saml/metadata", _ct); + result.StatusCode.ShouldBe(HttpStatusCode.OK); + result.Content.Headers.ContentType + .ShouldNotBeNull() + .MediaType + .ShouldBe(SamlConstants.ContentTypes.Metadata); + + var content = await result.Content.ReadAsStringAsync(_ct); + + var settings = new VerifySettings(); + var hostUri = Fixture.Url(); + settings.AddScrubber(sb => + { + sb.Replace(hostUri, "https://localhost"); + }); + + await Verify(content, settings); + } + + [Fact] + [Trait("Category", Category)] + public async Task metadata_should_include_valid_until_based_on_metadata_validity_duration() + { + Fixture.ConfigureSamlOptions = options => + { + options.MetadataValidityDuration = TimeSpan.FromDays(30); + }; + + await Fixture.InitializeAsync(); + + var result = await Fixture.Client.GetAsync("/saml/metadata", _ct); + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var content = await result.Content.ReadAsStringAsync(_ct); + + var expectedValidUntil = Fixture.Now.Add(TimeSpan.FromDays(30)).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ"); + content.ShouldContain($"validUntil=\"{expectedValidUntil}\""); + } + + [Fact] + [Trait("Category", Category)] + public async Task metadata_should_include_want_authn_requests_signed_when_enabled() + { + Fixture.ConfigureSamlOptions = options => + { + options.WantAuthnRequestsSigned = true; + }; + + await Fixture.InitializeAsync(); + + var result = await Fixture.Client.GetAsync("/saml/metadata", _ct); + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var content = await result.Content.ReadAsStringAsync(_ct); + + content.ShouldContain("WantAuthnRequestsSigned=\"true\""); + } + + [Fact] + [Trait("Category", Category)] + public async Task metadata_should_include_supported_name_id_formats() + { + Fixture.ConfigureSamlOptions = options => + { + options.SupportedNameIdFormats.Clear(); + options.SupportedNameIdFormats.Add(SamlConstants.NameIdentifierFormats.EmailAddress); + options.SupportedNameIdFormats.Add(SamlConstants.NameIdentifierFormats.Persistent); + }; + + await Fixture.InitializeAsync(); + + var result = await Fixture.Client.GetAsync("/saml/metadata", _ct); + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var content = await result.Content.ReadAsStringAsync(_ct); + + content.ShouldContain("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"); + content.ShouldContain("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"); + content.ShouldNotContain("urn:oasis:names:tc:SAML:2.0:nameid-format:transient"); + } + + [Fact] + [Trait("Category", Category)] + public async Task metadata_should_include_single_logout_service_endpoints() + { + await Fixture.InitializeAsync(); + + var result = await Fixture.Client.GetAsync("/saml/metadata", _ct); + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var content = await result.Content.ReadAsStringAsync(_ct); + + content.ShouldContain(" + { + // Configure with trailing slash to ensure it's handled correctly + options.UserInteraction.Route = "/saml/"; + }; + + await Fixture.InitializeAsync(); + + var result = await Fixture.Client.GetAsync("/saml/metadata", _ct); + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var content = await result.Content.ReadAsStringAsync(_ct); + + // Should not have double slashes + content.ShouldNotContain("saml//signin"); + content.ShouldNotContain("saml//logout"); + + var locationUrls = GetServiceLocationUrls(content, "SingleSignOnService", "SingleLogoutService"); + + foreach (var location in locationUrls) + { + location.ShouldNotEndWith("/"); + } + } + + [Fact] + [Trait("Category", Category)] + public async Task metadata_urls_should_handle_edge_case_route_configurations() + { + // Verify that default configuration produces clean URLs + // Edge cases (empty routes, whitespace, etc.) are comprehensively tested + // at the unit level in BuildServiceUrl unit tests + await Fixture.InitializeAsync(); + + var result = await Fixture.Client.GetAsync("/saml/metadata", _ct); + result.StatusCode.ShouldBe(HttpStatusCode.OK); + + var content = await result.Content.ReadAsStringAsync(_ct); + var locationUrls = GetServiceLocationUrls(content, "SingleSignOnService", "SingleLogoutService"); + + foreach (var location in locationUrls) + { + // Should have clean paths without double slashes + var uri = new Uri(location); + uri.PathAndQuery.ShouldNotContain("//"); + + // Should not have trailing slashes + location.ShouldNotEndWith("/"); + } + } + + private static List GetServiceLocationUrls(string xmlContent, params string[] serviceElementNames) + { + var doc = XDocument.Parse(xmlContent); + var ns = XNamespace.Get("urn:oasis:names:tc:SAML:2.0:metadata"); + var locations = new List(); + + foreach (var serviceName in serviceElementNames) + { + var services = doc.Descendants(ns + serviceName); + foreach (var service in services) + { + var location = service.Attribute("Location")?.Value; + location.ShouldNotBeNull($"{serviceName} should have a Location attribute"); + locations.Add(location); + } + } + + return locations; + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.metadata_endpoint_should_return_metadata.verified.txt b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.metadata_endpoint_should_return_metadata.verified.txt new file mode 100644 index 000000000..198c0c5b1 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlMetadataEndpointTests.metadata_endpoint_should_return_metadata.verified.txt @@ -0,0 +1 @@ +MIICojCCAYqgAwIBAgIIIjGqKDo3ME4wDQYJKoZIhvcNAQELBQAwETEPMA0GA1UEAxMGZm9vYmFyMB4XDTI1MTExMDE0MTEyNloXDTMwMTExMDE0MTEyNlowETEPMA0GA1UEAxMGZm9vYmFyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu/fcM55jlB810lyxGpgk0Zhw83Liqz80l3zLLAZgJ/IUdBx9VFD28BeO37eByHDXxBIQdHFYXQj+lv2g3KFRxVzfZhiFUrb1UydJYFZ951sQUEsP4T/Fpbyb95HNrwG2NwE5/fk1MXr9no4ydsQTZA6EWOfbxn6o2YQs/8QdDykhCzpZcWYbk5AKS/G6nYLpwuW4UsyMQ6ur9ZQXtwDS/hGyP3RjK8pjqkckbQG9ZapI+hWezIJkGmkXcuIx+FpZbdjjwu/SIcNNrBIXLbrbWyxoWt4y2jWfDixanBAubBLtx6tCg69trJ3M5gZkFZBR3CVqs78fYZUThKBTS20afQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCxB08tE2bDWpF5mR14kQvRUA/2hZKeC6CYYGEwOu1hbh5m3rVj4T9GPgOh+s6tX+rCb0IoV1uD9iSeTd3XaJ/1sSFkgVD/PaA6NRgzKVeDXLl9rZGAnOmp/Es3Pz35FbPxZKTe8UDyFHySbioLaLvtODhzX7SeGP3BcRpp8rZLvggMYiqo3w39+qZcgZPIBP4yRSulBYb3r9qagQ/n//gp7SmenCQmjA5L7pTn7QggFQsSQmB6dyNS54cUk0niUsTihT9oqpMnXmsXonXf5cv3tnaydreiB4aPea+OjjY3oy8hvHUH6FuQQX7t3RllZlPGJQFZe61rYMVmRRjlHWTAurn:oasis:names:tc:SAML:1.1:nameid-format:emailAddressurn:oasis:names:tc:SAML:2.0:nameid-format:persistenturn:oasis:names:tc:SAML:2.0:nameid-format:transienturn:oasis:names:tc:SAML:1.1:nameid-format:unspecified diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninCallbackEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninCallbackEndpointTests.cs new file mode 100644 index 000000000..cf3c45610 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninCallbackEndpointTests.cs @@ -0,0 +1,475 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Collections.ObjectModel; +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Web; +using Duende.IdentityModel; +using Duende.IdentityServer.Internal.Saml.SingleSignin; +using Duende.IdentityServer.Models; +using Microsoft.AspNetCore.Mvc; +using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers; +using StateId = Duende.IdentityServer.Internal.Saml.SingleSignin.Models.StateId; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +public class SamlSigninCallbackEndpointTests +{ + private const string Category = "SAML Signin Callback Endpoint"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private SamlFixture Fixture = new(); + + private SamlData Data => Fixture.Data; + + private SamlDataBuilder Build => Fixture.Builder; + + [Fact] + [Trait("Category", Category)] + public async Task callback_returns_error_when_state_not_found() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + signinResult.StatusCode.ShouldBe(HttpStatusCode.Found); + signinResult.Headers.Location.ShouldNotBeNull(); + + var stateId = ExtractStateIdFromCookie(signinResult); + stateId.ShouldNotBeNull(); + + // Remove state from store so the next request is sent with a state id that for state which no longer exists + var samlSigninStateStore = Fixture.Get(); + await samlSigninStateStore.RetrieveSigninRequestStateAsync(new StateId(Guid.Parse(stateId)), _ct); + + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"The request {stateId} could not be found."); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_returns_error_when_state_id_is_missing() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Do not make request to the sign-in endpoint first so no state id is created + + var result = await Fixture.Client.GetAsync("/saml/signin_callback", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("No state id could be found."); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_redirects_to_login_when_user_not_authenticated_and_state_is_found() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + signinResult.StatusCode.ShouldBe(HttpStatusCode.Found); + var redirectUri = signinResult.Headers.Location; + redirectUri.ShouldNotBeNull(); + redirectUri.ToString().ShouldStartWith("/saml/signin_callback"); + + await Fixture.NonRedirectingClient.GetAsync("/__signout", _ct); + + var result = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin_callback", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.Found); + var resultRedirectUri = result.Headers.Location; + resultRedirectUri.ShouldNotBeNull(); + HttpUtility.UrlDecode(resultRedirectUri.ToString()).ShouldBe($"{Fixture.LoginUrl}?ReturnUrl={Fixture.SignInCallbackUrl}"); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_returns_error_when_service_provider_not_found() + { + var sp = Build.SamlServiceProvider(); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + signinResult.StatusCode.ShouldBe(HttpStatusCode.Found); + var redirectUri = signinResult.Headers.Location; + redirectUri.ShouldNotBeNull(); + + Fixture.ClearServiceProvidersAsync(); + + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"Service Provider '{sp.EntityId}' is not registered or is disabled"); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_returns_error_when_service_provider_disabled() + { + var sp = Build.SamlServiceProvider(); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + signinResult.StatusCode.ShouldBe(HttpStatusCode.Found); + var redirectUri = signinResult.Headers.Location; + redirectUri.ShouldNotBeNull(); + + // Ideally we would fetch the SP via a store and update it, but since the store doesn't provide that functionality + // we'll rely on everything being in memory and holding onto a reference to the SP in the store for now + sp.Enabled = false; + + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"Service Provider '{sp.EntityId}' is not registered or is disabled"); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_state_is_not_persisted_after_successful_login() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + signinResult.StatusCode.ShouldBe(HttpStatusCode.Found); + var redirectUri = signinResult.Headers.Location; + redirectUri.ShouldNotBeNull(); + + var firstResult = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + // First use succeeds + firstResult.StatusCode.ShouldBe(HttpStatusCode.OK); + var html = await firstResult.Content.ReadAsStringAsync(_ct); + html.ShouldContain("SAMLResponse"); + + // Second callback with same stateId (replay attack) + var secondResult = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + // Second use should fail + secondResult.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await secondResult.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("No state id could be found."); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_returns_error_when_state_is_expired() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + signinResult.StatusCode.ShouldBe(HttpStatusCode.Found); + signinResult.Headers.Location.ShouldNotBeNull(); + + var stateId = ExtractStateIdFromCookie(signinResult); + + Fixture.Data.FakeTimeProvider.Advance(TimeSpan.FromMinutes(11)); + + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"The request {stateId} could not be found."); + } + + [Fact] + [Trait("Category", Category)] + public async Task state_contains_correct_request_information() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + var specificRelayState = "test-relay-state-123"; + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}&RelayState={specificRelayState}", _ct); + + signinResult.StatusCode.ShouldBe(HttpStatusCode.Found); + var redirectUri = signinResult.Headers.Location; + redirectUri.ShouldNotBeNull(); + + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var samlResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + samlResponse.ShouldNotBeNull(); + samlResponse.RelayState.ShouldBe(specificRelayState); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_returns_signed_response_when_sign_response_configured() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignResponse; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + signinResult.StatusCode.ShouldBe(HttpStatusCode.Found); + var redirectUri = signinResult.Headers.Location; + redirectUri.ShouldNotBeNull(); + + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: false); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success"); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_returns_signed_assertion_when_sign_assertion_configured() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignAssertion; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + signinResult.StatusCode.ShouldBe(HttpStatusCode.Found); + var redirectUri = signinResult.Headers.Location; + redirectUri.ShouldNotBeNull(); + + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: false, expectAssertionSignature: true); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success"); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_returns_unsigned_response_when_do_not_sign_configured() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.DoNotSign; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + signinResult.StatusCode.ShouldBe(HttpStatusCode.Found); + var redirectUri = signinResult.Headers.Location; + redirectUri.ShouldNotBeNull(); + + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: false, expectAssertionSignature: false); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success"); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_signature_works_with_maximum_attribute_payload() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignBoth; + sp.ClaimMappings = new ReadOnlyDictionary(new Dictionary + { + [JwtClaimTypes.Subject] = "sub", + [JwtClaimTypes.Name] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + [JwtClaimTypes.Email] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + [JwtClaimTypes.Role] = "http://schemas.xmlsoap.org/ws/2005/05/identity/role", + ["department"] = "ou", + ["location"] = "loc", + ["employee_id"] = "emp_id", + ["manager"] = "manager", + ["cost_center"] = "cc" + }); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Create user with many claims + var claims = new List + { + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(JwtClaimTypes.Name, "Test User"), + new Claim(JwtClaimTypes.Email, "test@example.com"), + new Claim(JwtClaimTypes.Role, "Admin"), + new Claim(JwtClaimTypes.Role, "User"), + new Claim("department", "Engineering"), + new Claim("location", "Seattle"), + new Claim("employee_id", "EMP12345"), + new Claim("manager", "manager@example.com"), + new Claim("cost_center", "CC-1234") + }; + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + signinResult.StatusCode.ShouldBe(HttpStatusCode.Found); + signinResult.Headers.Location.ShouldNotBeNull(); + + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: true); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success"); + successResponse.Assertion.Attributes.ShouldNotBeNull(); + successResponse.Assertion.Attributes!.Count.ShouldBeGreaterThan(4); // At least the claims we added + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_signature_preserves_xml_special_characters_in_attributes() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignAssertion; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var claims = new List + { + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(JwtClaimTypes.Name, "Test & \"Company\""), + new Claim("description", "Value with & \"quotes\" and 'apostrophes'"), + new Claim("xml_data", "text") + }; + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml, _ct); + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + signinResult.StatusCode.ShouldBe(HttpStatusCode.Found); + signinResult.Headers.Location.ShouldNotBeNull(); + + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: false, expectAssertionSignature: true); + + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + responseElement.ShouldNotBeNull(); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success"); + + var nameAttribute = successResponse.Assertion.Attributes?.FirstOrDefault(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"); + nameAttribute.ShouldNotBeNull(); + nameAttribute.Value.ShouldBe("Test & \"Company\""); + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninEndpointTests.cs new file mode 100644 index 000000000..6d36e4ee7 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSigninEndpointTests.cs @@ -0,0 +1,2785 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Collections.ObjectModel; +using System.IO.Compression; +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Web; +using Duende.IdentityModel; +using Duende.IdentityServer.Internal.Saml; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Microsoft.AspNetCore.Mvc; +using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +public class SamlSigninEndpointTests +{ + private const string Category = "SAML Signin Endpoint"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private SamlFixture Fixture = new(); + + private SamlData Data => Fixture.Data; + + private SamlDataBuilder Build => Fixture.Builder; + + [Fact] + [Trait("Category", Category)] + public async Task can_initiate_binding_redirected_signin() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + var queryStringValues = HttpUtility.ParseQueryString(requestUrl.Query); + queryStringValues["ReturnUrl"].ShouldBe("/saml/signin_callback"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_on_binding_redirected_signin_with_no_saml_request() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var result = await Fixture.Client.GetAsync("/saml/signin", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("Missing 'SAMLRequest' query parameter in SAML signin request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_on_binding_redirected_signin_with_invalid_base64() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var invalidBase64 = "not-valid-base64!!!"; + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={invalidBase64}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("Invalid base64 encoding in SAML signin request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_on_binding_redirected_signin_with_invalid_saml_request() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(version: "1.0"); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Should return SAML error response via HTTP-POST binding + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + + errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:VersionMismatch"); + errorResponse.StatusMessage.ShouldNotBeNull(); + errorResponse.StatusMessage.ShouldContain("Only Version 2.0 is supported"); + errorResponse.Issuer.ShouldBe(Fixture.Url()); + errorResponse.InResponseTo.ShouldNotBeNullOrEmpty(); + errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task can_initiate_binding_redirected_signin_with_relay_state() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + + var result = + await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}&RelayState={Data.RelayState}", _ct); + + var samlSuccessResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + samlSuccessResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + samlSuccessResponse.RelayState.ShouldBe(Data.RelayState); + } + + [Fact] + [Trait("Category", Category)] + public async Task can_initiate_form_post_signin() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var encodedRequest = ConvertToBase64Encoded(Build.AuthNRequestXml()); + var formData = new Dictionary + { + { "SAMLRequest", encodedRequest } + }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync($"/saml/signin?", content, _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + var queryStringValues = HttpUtility.ParseQueryString(requestUrl.Query); + queryStringValues["ReturnUrl"].ShouldBe("/saml/signin_callback"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_on_form_post_signin_with_no_saml_request() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var content = new FormUrlEncodedContent(new Dictionary()); + + var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("Missing 'SAMLRequest' form parameter in SAML signin request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_on_form_post_signin_with_invalid_base64() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var invalidBase64 = "not-valid-base64!!!"; + var formData = new Dictionary + { + { "SAMLRequest", invalidBase64 } + }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("Invalid base64 encoding in SAML signin request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_on_binding_form_post_with_invalid_saml_request() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(); + var encodedRequest = ConvertToBase64Encoded(Build.AuthNRequestXml(version: "1.0")); + var formData = new Dictionary + { + { "SAMLRequest", encodedRequest } + }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync($"/saml/signin?", content, _ct); + + // Should return SAML error response via HTTP-POST binding + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + + errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:VersionMismatch"); + errorResponse.StatusMessage.ShouldNotBeNull(); + errorResponse.StatusMessage.ShouldContain("Only Version 2.0 is supported"); + errorResponse.Issuer.ShouldBe(Fixture.Url()); + errorResponse.InResponseTo.ShouldNotBeNullOrEmpty(); + errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task can_initiate_form_post_signin_with_relay_state() + { + Data.RelayState = "test_relay_state"; + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var encodedRequest = ConvertToBase64Encoded(Build.AuthNRequestXml()); + var formData = new Dictionary + { + { "SAMLRequest", encodedRequest }, + { "RelayState", Data.RelayState } + }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync($"/saml/signin?", content, _ct); + + var samlSuccessResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + samlSuccessResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + samlSuccessResponse.RelayState.ShouldBe(Data.RelayState); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_request_is_within_clock_skew_range() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Create a request with IssueInstant 2 minutes in the future (within default 5 minute clock skew) + var slightlyFutureTime = Data.Now.AddMinutes(2); + var authnRequestXml = Build.AuthNRequestXml(issueInstant: slightlyFutureTime); + + var urlEncoded = await EncodeRequest(authnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Should succeed and redirect to login + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task returns_error_when_request_issue_instant_is_in_future() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Create a request with IssueInstant 10 minutes in the future (beyond default clock skew) + var futureTime = Data.Now.AddMinutes(10); + var authnRequestXml = Build.AuthNRequestXml(futureTime); + + var urlEncoded = await EncodeRequest(authnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester"); + errorResponse.StatusMessage.ShouldNotBeNull(); + errorResponse.StatusMessage.ShouldContain("IssueInstant is in the future"); + } + + [Fact] + [Trait("Category", Category)] + public async Task returns_error_when_request_issue_instant_exceeds_service_provider_configured_clock_skew() + { + var serviceProviderClockSkew = TimeSpan.FromMinutes(2); + var sp = Build.SamlServiceProvider(); + sp.ClockSkew = serviceProviderClockSkew; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var futureIssueInstant = Data.Now.Add(serviceProviderClockSkew) + TimeSpan.FromSeconds(1); + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(issueInstant: futureIssueInstant)); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + + errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester"); + errorResponse.StatusMessage.ShouldBe("Request IssueInstant is in the future"); + errorResponse.Issuer.ShouldBe(Fixture.Url()); + errorResponse.InResponseTo.ShouldNotBeNullOrEmpty(); + errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_request_is_expired() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Create a request with IssueInstant 10 minutes in the past (beyond default max age) + var pastTime = Data.Now.AddMinutes(-10); + var authnRequestXml = Build.AuthNRequestXml(pastTime); + + var urlEncoded = await EncodeRequest(authnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester"); + errorResponse.StatusMessage.ShouldNotBeNull(); + errorResponse.StatusMessage.ShouldContain("expired"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_request_is_expired_with_custom_request_max_age() + { + var sp = Build.SamlServiceProvider(); + sp.RequestMaxAge = TimeSpan.FromMinutes(20); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var pastTime = Data.Now.AddMinutes(-21); + var authnRequestXml = Build.AuthNRequestXml(pastTime); + + var urlEncoded = await EncodeRequest(authnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester"); + errorResponse.StatusMessage.ShouldNotBeNull(); + errorResponse.StatusMessage.ShouldContain("expired"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_destination_is_invalid() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Use an invalid destination URL + var authnRequestXml = Build.AuthNRequestXml(destination: new Uri("https://wrong.example.com/saml/sso")); + + var urlEncoded = await EncodeRequest(authnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester"); + errorResponse.StatusMessage.ShouldNotBeNull(); + errorResponse.StatusMessage.ShouldContain("Invalid destination"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_acs_url_is_not_registered() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Use an ACS URL that's not registered with the SP + var authnRequestXml = Build.AuthNRequestXml(acsUrl: new Uri("https://sp.example.com/wrong-callback")); + + var urlEncoded = await EncodeRequest(authnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldNotBeNull(); + problemDetails.Detail.ShouldContain("AssertionConsumerServiceUrl"); + problemDetails.Detail.ShouldContain("is not valid"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_acs_index_is_invalid() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Add an AssertionConsumerServiceIndex that doesn't exist + var authnRequestXml = Build.AuthNRequestXml() + .Replace("AssertionConsumerServiceURL=\"" + Data.AcsUrl + "\"", + "AssertionConsumerServiceIndex=\"99\""); + + var urlEncoded = await EncodeRequest(authnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldNotBeNull(); + problemDetails.Detail.ShouldContain("AssertionConsumerServiceIndex"); + problemDetails.Detail.ShouldContain("is not valid"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_service_provider_has_no_configured_acs_urls() + { + var sp = Build.SamlServiceProvider(); + sp.AssertionConsumerServiceUrls = []; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml() + .Replace("AssertionConsumerServiceURL=\"" + Data.AcsUrl + "\"", + string.Empty); + + var urlEncoded = await EncodeRequest(authnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldNotBeNull(); + problemDetails.Detail.ShouldContain( + $"The Service Provider '{Data.EntityId}' does not have any configured Assertion Consumer Service URLs"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_service_provider_is_not_enabled() + { + var sp = Build.SamlServiceProvider(); + sp.Enabled = false; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(); + + var urlEncoded = await EncodeRequest(authnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldNotBeNull(); + problemDetails.Detail.ShouldContain($"Service Provider '{Data.EntityId}' is not registered or is disabled"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_request_uses_default_acs_url() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml() + .Replace("AssertionConsumerServiceURL=\"" + Data.AcsUrl + "\"", + string.Empty); + + var urlEncoded = await EncodeRequest(authnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_destination_matches() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Use the correct Destination attribute + var correctDestination = new Uri(Fixture.Url() + "/saml/signin"); + var authnRequestXml = Build.AuthNRequestXml(destination: correctDestination); + + var urlEncoded = await EncodeRequest(authnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Should succeed and redirect to login + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_user_is_already_logged_in_at_identity_provider() + { + var sp = Build.SamlServiceProvider(); + sp.ClaimMappings = new ReadOnlyDictionary(new Dictionary + { + ["sub"] = "sub", + ["idp"] = "idp", + ["amr"] = "amr", + ["auth_time"] = "auth_time" + }); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + + successResponse.ResponseId.ShouldNotBeNullOrEmpty(); + successResponse.Destination.ShouldBe(Fixture.Data.AcsUrl.ToString()); + successResponse.IssueInstant.ShouldBe(Data.FakeTimeProvider.GetUtcNow().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")); + successResponse.Issuer.ShouldBe(Fixture.Url()); + successResponse.InResponseTo.ShouldBe(Data.RequestId); + + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + + var assertion = successResponse.Assertion; + assertion.ShouldNotBeNull(); + assertion.Id.ShouldNotBeNullOrEmpty(); + assertion.Version.ShouldBe("2.0"); + assertion.IssueInstant.ShouldBe(Data.FakeTimeProvider.GetUtcNow().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")); + assertion.Issuer.ShouldBe(Fixture.Url()); + + var subject = assertion.Subject; + subject.ShouldNotBeNull(); + subject.NameId.ShouldBe("user-id"); + subject.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Unspecified); + subject.SPNameQualifier.ShouldBeNull(); + + var subjectConfirmation = subject.SubjectConfirmation; + subjectConfirmation.ShouldNotBeNull(); + subjectConfirmation.Method.ShouldBe("urn:oasis:names:tc:SAML:2.0:cm:bearer"); + + var subjectConfirmationData = subjectConfirmation.SubjectConfirmationData; + subjectConfirmationData.ShouldNotBeNull(); + subjectConfirmationData.NotOnOrAfter.ShouldBe(Data.FakeTimeProvider.GetUtcNow().Add(Build.SamlServiceProvider().RequestMaxAge!.Value).ToString("yyyy-MM-ddTHH:mm:ss.fffZ")); + subjectConfirmationData.Recipient.ShouldBe(Fixture.Data.AcsUrl.ToString()); + subjectConfirmationData.InResponseTo.ShouldBe(Data.RequestId); + + var conditions = assertion.Conditions; + conditions.ShouldNotBeNull(); + conditions.NotBefore.ShouldBe(Data.FakeTimeProvider.GetUtcNow().Subtract(Build.SamlServiceProvider().ClockSkew!.Value).ToString("yyyy-MM-ddTHH:mm:ss.fffZ")); + conditions.NotOnOrAfter.ShouldBe(Data.FakeTimeProvider.GetUtcNow().Add(Build.SamlServiceProvider().RequestMaxAge!.Value).ToString("yyyy-MM-ddTHH:mm:ss.fffZ")); + conditions.Audience.ShouldBe(Data.EntityId.ToString()); + + var authnStatement = assertion.AuthnStatement; + authnStatement.ShouldNotBeNull(); + authnStatement.AuthnInstant.ShouldBe(Data.FakeTimeProvider.GetUtcNow().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")); + authnStatement.SessionIndex.ShouldNotBeNullOrEmpty(); + authnStatement.AuthnContextClassRef.ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified"); + + var attributes = assertion.Attributes; + attributes.ShouldNotBeNull(); + attributes.Count.ShouldBe(4); + + var subAttribute = attributes.FirstOrDefault(a => a.Name == "sub"); + subAttribute.ShouldNotBeNull(); + subAttribute.NameFormat.ShouldBe(SamlConstants.AttributeNameFormats.Uri); + subAttribute.FriendlyName.ShouldBe("sub"); + subAttribute.Value.ShouldBe("user-id"); + + var idpAttribute = attributes.FirstOrDefault(a => a.Name == "idp"); + idpAttribute.ShouldNotBeNull(); + idpAttribute.NameFormat.ShouldBe(SamlConstants.AttributeNameFormats.Uri); + idpAttribute.FriendlyName.ShouldBe("idp"); + idpAttribute.Value.ShouldBe("local"); + + var amrAttribute = attributes.FirstOrDefault(a => a.Name == "amr"); + amrAttribute.ShouldNotBeNull(); + amrAttribute.NameFormat.ShouldBe(SamlConstants.AttributeNameFormats.Uri); + amrAttribute.FriendlyName.ShouldBe("amr"); + amrAttribute.Value.ShouldBe("pwd"); + + var authTimeAttribute = attributes.FirstOrDefault(a => a.Name == "auth_time"); + authTimeAttribute.ShouldNotBeNull(); + authTimeAttribute.NameFormat.ShouldBe(SamlConstants.AttributeNameFormats.Uri); + authTimeAttribute.FriendlyName.ShouldBe("auth_time"); + authTimeAttribute.Value.ShouldBe(Data.FakeTimeProvider.GetUtcNow().ToUnixTimeSeconds().ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_user_is_already_logged_in_at_identity_provider_but_request_includes_force_authn() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(forceAuthn: true); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + } + + private async Task EncodeRequest(string authenticationRequest) + { + var bytes = Encoding.UTF8.GetBytes(authenticationRequest); + using var outputStream = new MemoryStream(); + await using (var deflateStream = new DeflateStream(outputStream, CompressionMode.Compress, leaveOpen: true)) + { + await deflateStream.WriteAsync(bytes, 0, bytes.Length, _ct); + } + + var compressedBytes = outputStream.ToArray(); + var base64 = Convert.ToBase64String(compressedBytes); + var urlEncoded = Uri.EscapeDataString(base64); + return urlEncoded; + } + + private string ConvertToBase64Encoded(string authenticationRequest) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(authenticationRequest)); + + /// + /// Extracts SAML error response from an HTTP-POST binding auto-submit form. + /// + private async Task ExtractSamlErrorFromPostAsync(HttpResponseMessage response) + { + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html"); + + var html = await response.Content.ReadAsStringAsync(_ct); + + // Extract SAMLResponse from hidden input field + var samlResponseMatch = System.Text.RegularExpressions.Regex.Match( + html, + @"]+name=""SAMLResponse""[^>]+value=""([^""]+)""", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + samlResponseMatch.Success.ShouldBeTrue("SAMLResponse input field not found in HTML"); + var encodedResponse = samlResponseMatch.Groups[1].Value; + + // Extract RelayState if present + string? relayState = null; + var relayStateMatch = System.Text.RegularExpressions.Regex.Match( + html, + @"]+name=""RelayState""[^>]+value=""([^""]+)""", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (relayStateMatch.Success) + { + relayState = HttpUtility.HtmlDecode(relayStateMatch.Groups[1].Value); + } + + // Extract form action (ACS URL) + var actionMatch = System.Text.RegularExpressions.Regex.Match( + html, + @"]+action=""([^""]+)""", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + actionMatch.Success.ShouldBeTrue("Form action not found in HTML"); + var acsUrl = HttpUtility.HtmlDecode(actionMatch.Groups[1].Value); + + // Decode the SAML response + var decodedBytes = Convert.FromBase64String(HttpUtility.HtmlDecode(encodedResponse)); + var responseXml = Encoding.UTF8.GetString(decodedBytes); + + // Parse the SAML response XML + var doc = System.Xml.Linq.XDocument.Parse(responseXml); + var samlpNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:protocol"); + var samlNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:assertion"); + + var responseElement = doc.Root; + responseElement.ShouldNotBeNull(); + responseElement.Name.ShouldBe(samlpNs + "Response"); + + var responseId = responseElement.Attribute("ID")?.Value; + var inResponseTo = responseElement.Attribute("InResponseTo")?.Value; + var destination = responseElement.Attribute("Destination")?.Value; + var issueInstant = responseElement.Attribute("IssueInstant")?.Value; + + var issuer = responseElement.Element(samlNs + "Issuer")?.Value; + + var statusElement = responseElement.Element(samlpNs + "Status"); + statusElement.ShouldNotBeNull(); + + var statusCodeElement = statusElement.Element(samlpNs + "StatusCode"); + statusCodeElement.ShouldNotBeNull(); + var statusCode = statusCodeElement.Attribute("Value")?.Value; + + var statusMessage = statusElement.Element(samlpNs + "StatusMessage")?.Value; + + // Check for sub-status code + var subStatusCodeElement = statusCodeElement.Element(samlpNs + "StatusCode"); + var subStatusCode = subStatusCodeElement?.Attribute("Value")?.Value; + + return new SamlErrorResponseData + { + ResponseId = responseId, + InResponseTo = inResponseTo, + Destination = destination, + IssueInstant = issueInstant, + Issuer = issuer, + StatusCode = statusCode, + StatusMessage = statusMessage, + SubStatusCode = subStatusCode, + RelayState = relayState, + AssertionConsumerServiceUrl = acsUrl + }; + } + + private record SamlErrorResponseData + { + public string? ResponseId { get; init; } + public string? InResponseTo { get; init; } + public string? Destination { get; init; } + public string? IssueInstant { get; init; } + public string? Issuer { get; init; } + public string? StatusCode { get; init; } + public string? StatusMessage { get; init; } + public string? SubStatusCode { get; init; } + public string? RelayState { get; init; } + public string? AssertionConsumerServiceUrl { get; init; } + } + + [Fact] + [Trait("Category", Category)] + public async Task redirects_to_consent_when_require_consent_is_true() + { + var sp = Build.SamlServiceProvider(); + sp.RequireConsent = true; + Fixture.ServiceProviders.Add(sp); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.ConsentUrl.ToString()); + var queryStringValues = HttpUtility.ParseQueryString(requestUrl.Query); + queryStringValues["ReturnUrl"].ShouldBe("/saml/signin_callback"); + } + + [Fact] + [Trait("Category", Category)] + public async Task returns_error_when_is_passive_and_user_not_authenticated() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(isPassive: true)); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + + errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:NoPassive"); + errorResponse.StatusMessage.ShouldBe("The user is not currently logged in and passive login was requested."); + errorResponse.Issuer.ShouldBe(Fixture.Url()); + errorResponse.InResponseTo.ShouldNotBeNullOrEmpty(); + errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task returns_no_passive_error_when_both_force_authn_and_is_passive_are_true() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(forceAuthn: true, isPassive: true); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + + errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:NoPassive"); + errorResponse.StatusMessage.ShouldBe("The user is not currently logged in"); + errorResponse.Issuer.ShouldBe(Fixture.Url()); + errorResponse.InResponseTo.ShouldNotBeNullOrEmpty(); + errorResponse.AssertionConsumerServiceUrl.ShouldBe(Data.AcsUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_is_passive_true_and_user_authenticated() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(isPassive: true); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task forces_authentication_when_force_authn_true_and_user_authenticated() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Verify user is authenticated by doing a normal request first + var normalRequestXml = Build.AuthNRequestXml(forceAuthn: false); + var normalUrlEncoded = await EncodeRequest(normalRequestXml); + var normalResult = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={normalUrlEncoded}", _ct); + + // Without ForceAuthn, authenticated user goes directly to callback + normalResult.StatusCode.ShouldBe(HttpStatusCode.OK); + var samlSuccessResponse = await ExtractSamlSuccessFromPostAsync(normalResult, _ct); + samlSuccessResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + + var forceAuthnRequestXml = Build.AuthNRequestXml(forceAuthn: true); + var forceAuthnUrlEncoded = await EncodeRequest(forceAuthnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={forceAuthnUrlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + + var queryStringValues = HttpUtility.ParseQueryString(requestUrl.Query); + var returnUrl = queryStringValues["ReturnUrl"]; + returnUrl.ShouldBe("/saml/signin_callback"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_issue_instant_at_exact_clock_skew_boundary() + { + var clockSkew = TimeSpan.FromMinutes(5); + var sp = Build.SamlServiceProvider(); + sp.ClockSkew = clockSkew; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var exactBoundaryTime = Data.Now.Add(clockSkew); + var authnRequestXml = Build.AuthNRequestXml(issueInstant: exactBoundaryTime); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_issue_instant_one_second_beyond_clock_skew() + { + var clockSkew = TimeSpan.FromMinutes(5); + var sp = Build.SamlServiceProvider(); + sp.ClockSkew = clockSkew; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var beyondBoundaryTime = Data.Now.Add(clockSkew).AddSeconds(1); + var authnRequestXml = Build.AuthNRequestXml(issueInstant: beyondBoundaryTime); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester"); + errorResponse.StatusMessage.ShouldBe("Request IssueInstant is in the future"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_clock_skew_is_zero() + { + var sp = Build.SamlServiceProvider(); + sp.ClockSkew = TimeSpan.Zero; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var exactTime = Data.Now; + var authnRequestXml = Build.AuthNRequestXml(issueInstant: exactTime); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_clock_skew_is_zero_and_time_differs() + { + var sp = Build.SamlServiceProvider(); + sp.ClockSkew = TimeSpan.Zero; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var futureTime = Data.Now.AddSeconds(1); + var authnRequestXml = Build.AuthNRequestXml(issueInstant: futureTime); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester"); + errorResponse.StatusMessage.ShouldBe("Request IssueInstant is in the future"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_issue_instant_at_max_age_boundary() + { + var maxAge = TimeSpan.FromMinutes(5); + var sp = Build.SamlServiceProvider(); + sp.RequestMaxAge = maxAge; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var exactMaxAgeTime = Data.Now.Subtract(maxAge); + var authnRequestXml = Build.AuthNRequestXml(issueInstant: exactMaxAgeTime); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_issue_instant_one_second_beyond_max_age() + { + var maxAge = TimeSpan.FromMinutes(5); + var sp = Build.SamlServiceProvider(); + sp.RequestMaxAge = maxAge; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var beyondMaxAgeTime = Data.Now.Subtract(maxAge).AddSeconds(-1); + var authnRequestXml = Build.AuthNRequestXml(issueInstant: beyondMaxAgeTime); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + errorResponse.StatusCode.ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Requester"); + errorResponse.StatusMessage.ShouldBe("Request has expired (IssueInstant too old)"); + } + + [Fact] + [Trait("Category", Category)] + public async Task uses_acs_url_when_both_url_and_index_provided() + { + var primaryAcsUrl = new Uri("https://sp.example.com/callback1"); + var secondaryAcsUrl = new Uri("https://sp.example.com/callback2"); + + var sp = Build.SamlServiceProvider(); + sp.AssertionConsumerServiceUrls = [primaryAcsUrl, secondaryAcsUrl]; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml( + acsUrl: primaryAcsUrl, + acsIndex: 1 // Points to secondary URL + ); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + + var queryStringValues = HttpUtility.ParseQueryString(requestUrl.Query); + var returnUrl = queryStringValues["ReturnUrl"]; + returnUrl.ShouldBe("/saml/signin_callback"); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var signinCallbackResponse = await Fixture.Client.GetAsync(returnUrl, _ct); + + var samlSuccessResponse = await ExtractSamlSuccessFromPostAsync(signinCallbackResponse, _ct); + samlSuccessResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + samlSuccessResponse.Destination.ShouldBe(primaryAcsUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_request_has_no_id_attribute() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(); + var xmlWithoutId = authnRequestXml.Replace($"ID=\"{Data.RequestId}\"", ""); + + var urlEncoded = await EncodeRequest(xmlWithoutId); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + //TODO: do we want more specific errors returned? + problemDetails.Detail.ShouldBe("Invalid SAMLRequest format in SAML signin request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_request_has_empty_id() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(requestId: ""); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldNotBeNull(); + //TODO: do we want more specific errors returned? + problemDetails.Detail.ShouldBe("Invalid SAMLRequest format in SAML signin request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_request_issuer_is_empty() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(issuer: ""); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + //TODO: do we want more specific errors returned? + problemDetails.Detail.ShouldBe("Invalid SAMLRequest format in SAML signin request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_request_issuer_is_missing() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(); + var xmlWithoutIssuer = authnRequestXml.Replace( + $"{Data.EntityId}", + "" + ); + + var urlEncoded = await EncodeRequest(xmlWithoutIssuer); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + //TODO: do we want more specific errors returned? + problemDetails.Detail.ShouldBe("Invalid SAMLRequest format in SAML signin request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_response_is_signed_with_sign_response_behavior() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignResponse; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: false); + + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + VerifySignaturePositionAfterIssuer(responseElement); + + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_response_is_signed_with_sign_assertion_behavior() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignAssertion; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: false, expectAssertionSignature: true); + + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var assertionElement = responseElement.Element(samlNs + "Assertion"); + assertionElement.ShouldNotBeNull(); + VerifySignaturePositionAfterIssuer(assertionElement!); + + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_response_is_signed_with_sign_both_behavior() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignBoth; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: true); + + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + VerifySignaturePositionAfterIssuer(responseElement); + + var assertionElement = responseElement.Element(samlNs + "Assertion"); + assertionElement.ShouldNotBeNull(); + VerifySignaturePositionAfterIssuer(assertionElement!); + + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_response_is_not_signed_with_do_not_sign_behavior() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.DoNotSign; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: false, expectAssertionSignature: false); + + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_response_uses_default_signing_behavior_with_null() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = null; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + // Default behavior should be SignAssertion per SAML best practices + VerifySignaturePresence(responseXml, expectResponseSignature: false, expectAssertionSignature: true); + + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task response_signature_contains_correct_reference_to_response_id() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignResponse; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + + // Extract signature and verify Reference URI points to Response ID + var signatureElement = ExtractSignatureElement(responseElement); + signatureElement.ShouldNotBeNull("Response should have a Signature element"); + + var referenceUri = GetSignatureReferenceUri(signatureElement!); + referenceUri.ShouldNotBeNull(); + referenceUri.ShouldStartWith("#"); + + var responseId = responseElement.Attribute("ID")?.Value; + responseId.ShouldNotBeNullOrEmpty(); + referenceUri.ShouldBe($"#{responseId}"); + } + + [Fact] + [Trait("Category", Category)] + public async Task assertion_signature_contains_correct_reference_to_assertion_id() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignAssertion; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + + // Extract Assertion and its signature + var assertionElement = responseElement.Element(samlNs + "Assertion"); + assertionElement.ShouldNotBeNull("Response should contain an Assertion"); + + var signatureElement = ExtractSignatureElement(assertionElement!); + signatureElement.ShouldNotBeNull(); + + var referenceUri = GetSignatureReferenceUri(signatureElement!); + referenceUri.ShouldNotBeNull(); + referenceUri.ShouldStartWith("#"); + + var assertionId = assertionElement!.Attribute("ID")?.Value; + assertionId.ShouldNotBeNullOrEmpty(); + referenceUri.ShouldBe($"#{assertionId}"); + } + + [Fact] + [Trait("Category", Category)] + public async Task response_signature_contains_key_info_with_certificate() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignResponse; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + + // Extract signature and verify KeyInfo structure + var signatureElement = ExtractSignatureElement(responseElement); + signatureElement.ShouldNotBeNull(); + + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + var keyInfoElement = signatureElement!.Element(dsNs + "KeyInfo"); + keyInfoElement.ShouldNotBeNull("Signature should contain KeyInfo element"); + + var x509DataElement = keyInfoElement!.Element(dsNs + "X509Data"); + x509DataElement.ShouldNotBeNull("KeyInfo should contain X509Data element"); + + var x509CertificateElement = x509DataElement!.Element(dsNs + "X509Certificate"); + x509CertificateElement.ShouldNotBeNull("X509Data should contain X509Certificate element"); + + var certificateData = x509CertificateElement!.Value; + certificateData.ShouldNotBeNullOrEmpty("X509Certificate should contain certificate data"); + + var signingCert = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(SamlFixture.StableSigningCert), null); + certificateData.ShouldBe(Convert.ToBase64String(signingCert.Export(X509ContentType.Cert))); + } + + [Fact] + [Trait("Category", Category)] + public async Task response_signature_uses_sha256_digest_method() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignResponse; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + + // Parse signature algorithms + var signatureElement = ExtractSignatureElement(responseElement); + signatureElement.ShouldNotBeNull(); + + var signatureInfo = ParseSignatureInfo(signatureElement!); + + // Verify SHA256 digest method + signatureInfo.DigestMethod.ShouldNotBeNull("Signature should specify DigestMethod"); + signatureInfo.DigestMethod.ShouldBe("http://www.w3.org/2001/04/xmlenc#sha256", + "DigestMethod should be SHA256"); + + // Verify RSA-SHA256 signature method + signatureInfo.SignatureMethod.ShouldNotBeNull("Signature should specify SignatureMethod"); + signatureInfo.SignatureMethod.ShouldBe("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "SignatureMethod should be RSA-SHA256"); + } + + [Fact] + [Trait("Category", Category)] + public async Task response_signature_uses_exclusive_canonicalization() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignResponse; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + + // Parse signature canonicalization + var signatureElement = ExtractSignatureElement(responseElement); + signatureElement.ShouldNotBeNull(); + + var signatureInfo = ParseSignatureInfo(signatureElement!); + + // Verify Exclusive Canonicalization (C14N) + signatureInfo.CanonicalizationMethod.ShouldNotBeNull("Signature should specify CanonicalizationMethod"); + signatureInfo.CanonicalizationMethod.ShouldBe("http://www.w3.org/2001/10/xml-exc-c14n#", + "CanonicalizationMethod should be Exclusive C14N"); + } + + [Fact] + [Trait("Category", Category)] + public async Task assertion_signature_uses_correct_algorithms() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignAssertion; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + + // Extract Assertion signature and verify algorithms + var assertionElement = responseElement.Element(samlNs + "Assertion"); + assertionElement.ShouldNotBeNull(); + + var signatureElement = ExtractSignatureElement(assertionElement!); + signatureElement.ShouldNotBeNull(); + + var signatureInfo = ParseSignatureInfo(signatureElement!); + + // Verify all algorithms match SAML 2.0 best practices + signatureInfo.CanonicalizationMethod.ShouldBe("http://www.w3.org/2001/10/xml-exc-c14n#"); + signatureInfo.SignatureMethod.ShouldBe("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"); + signatureInfo.DigestMethod.ShouldBe("http://www.w3.org/2001/04/xmlenc#sha256"); + } + + [Fact] + [Trait("Category", Category)] + public async Task sign_both_uses_consistent_algorithms_for_both_signatures() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignBoth; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + + // Extract and verify Response signature + var responseSignature = ExtractSignatureElement(responseElement); + responseSignature.ShouldNotBeNull(); + var responseSignatureInfo = ParseSignatureInfo(responseSignature!); + + // Extract and verify Assertion signature + var assertionElement = responseElement.Element(samlNs + "Assertion"); + assertionElement.ShouldNotBeNull(); + var assertionSignature = ExtractSignatureElement(assertionElement!); + assertionSignature.ShouldNotBeNull(); + var assertionSignatureInfo = ParseSignatureInfo(assertionSignature!); + + // Both signatures should use the same algorithms + responseSignatureInfo.CanonicalizationMethod.ShouldBe(assertionSignatureInfo.CanonicalizationMethod, + "Both signatures should use same canonicalization method"); + responseSignatureInfo.SignatureMethod.ShouldBe(assertionSignatureInfo.SignatureMethod, + "Both signatures should use same signature method"); + responseSignatureInfo.DigestMethod.ShouldBe(assertionSignatureInfo.DigestMethod, + "Both signatures should use same digest method"); + } + + [Fact] + [Trait("Category", Category)] + public async Task signed_response_includes_relay_state() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignResponse; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + var relayStateValue = "test-relay-state-123"; + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}&RelayState={relayStateValue}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: false); + + successResponse.RelayState.ShouldBe(relayStateValue); + + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task signed_response_works_with_force_authn() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignResponse; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var normalRequestXml = Build.AuthNRequestXml(forceAuthn: false); + var normalUrlEncoded = await EncodeRequest(normalRequestXml); + var normalResult = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={normalUrlEncoded}", _ct); + var normalResponse = await ExtractSamlSuccessFromPostAsync(normalResult, _ct); + normalResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + + var forceAuthnRequestXml = Build.AuthNRequestXml(forceAuthn: true); + var forceAuthnUrlEncoded = await EncodeRequest(forceAuthnRequestXml); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={forceAuthnUrlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + + var queryStringValues = HttpUtility.ParseQueryString(requestUrl.Query); + var returnUrl = queryStringValues["ReturnUrl"]; + returnUrl.ShouldBe("/saml/signin_callback"); + } + + [Fact] + [Trait("Category", Category)] + public async Task signed_response_works_with_is_passive() + { + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignBoth; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(isPassive: true); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: true); + + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task signed_response_works_with_custom_acs_index() + { + var primaryAcsUrl = new Uri("https://sp.example.com/callback1"); + var secondaryAcsUrl = new Uri("https://sp.example.com/callback2"); + + var sp = Build.SamlServiceProvider(); + sp.SigningBehavior = SamlSigningBehavior.SignResponse; + sp.AssertionConsumerServiceUrls = [primaryAcsUrl, secondaryAcsUrl]; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(acsIndex: 1); + var urlEncoded = await EncodeRequest(authnRequestXml); + + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + var (responseXml, _, _) = await ExtractSamlResponse(result, _ct); + + VerifySignaturePresence(responseXml, expectResponseSignature: true, expectAssertionSignature: false); + + successResponse.Destination.ShouldBe(secondaryAcsUrl.ToString()); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_required_but_request_not_signed_redirect_binding() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCodes.Requester); + samlError.StatusMessage.ShouldBe("Missing signature parameter"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_required_but_request_not_signed_post_binding() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var encodedRequest = ConvertToBase64Encoded(Build.AuthNRequestXml()); + var formData = new Dictionary { { "SAMLRequest", encodedRequest } }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCodes.Requester); + samlError.StatusMessage.ShouldBe("Signature element not found"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_required_but_signature_invalid_redirect_binding() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var wrongCert = CreateTestSigningCertificate(Data.FakeTimeProvider, "CN=Wrong Certificate"); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, wrongCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCodes.Requester); + samlError.StatusMessage.ShouldBe("Invalid signature"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_required_but_signature_invalid_post_binding() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var wrongCert = CreateTestSigningCertificate(Data.FakeTimeProvider, "CN=Wrong Certificate"); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var signedXml = SignAuthNRequestXml(Build.AuthNRequestXml(), wrongCert); + var encodedRequest = ConvertToBase64Encoded(signedXml); + var formData = new Dictionary { { "SAMLRequest", encodedRequest } }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCodes.Requester); + samlError.StatusMessage.ShouldBe("Invalid signature"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_required_but_no_sp_certificates_configured() + { + var sp = Build.SamlServiceProvider(signingCertificate: null, requireSignedAuthnRequests: true); + Fixture.ServiceProviders.Add(sp); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"Service Provider '{sp.EntityId}' has no signing certificates configured and has sent a SAML signin request which requires signature validation"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_redirect_binding_request_correctly_signed() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, signingCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_post_binding_request_correctly_signed() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var signedXml = SignAuthNRequestXml(Build.AuthNRequestXml(), signingCert); + var encodedRequest = ConvertToBase64Encoded(signedXml); + var formData = new Dictionary { { "SAMLRequest", encodedRequest } }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_signature_optional_and_request_not_signed() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(requireSignedAuthnRequests: false)); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_signature_optional_and_request_is_signed() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: false)); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, signingCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_multiple_certificates_and_one_matches() + { + var cert1 = CreateTestSigningCertificate(Data.FakeTimeProvider, "CN=Certificate 1"); + var cert2 = CreateTestSigningCertificate(Data.FakeTimeProvider, "CN=Certificate 2"); + var cert3 = CreateTestSigningCertificate(Data.FakeTimeProvider, "CN=Certificate 3"); + + var sp = new SamlServiceProvider + { + EntityId = Data.EntityId, + DisplayName = "Example SP", + Description = "Example SP", + Enabled = true, + RequireSignedAuthnRequests = true, + SigningCertificates = [cert1, cert2, cert3], + AssertionConsumerServiceUrls = [Data.AcsUrl], + AssertionConsumerServiceBinding = SamlBinding.HttpPost, + RequestMaxAge = TimeSpan.FromMinutes(5), + ClockSkew = TimeSpan.FromMinutes(5) + }; + + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, cert2); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_redirect_binding_signature_algorithm_unsupported() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + // Sign with SHA1 (deprecated/unsupported) + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, signingCert, + "http://www.w3.org/2000/09/xmldsig#rsa-sha1"); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCodes.Requester); + samlError.StatusMessage.ShouldBe("Unsupported signature algorithm: http://www.w3.org/2000/09/xmldsig#rsa-sha1"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_redirect_binding_signature_has_query_order_incorrect() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var relayState = "relayState"; + // Pass urlEncoded and relayState in incorrect order for signing to cause a bad signature + var (signature, sigAlg) = SignAuthNRequestRedirect(relayState, urlEncoded, signingCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&SigAlg={Uri.EscapeDataString(sigAlg)}&Signature={Uri.EscapeDataString(signature)}", _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCodes.Requester); + samlError.StatusMessage.ShouldBe("Invalid signature"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_redirect_binding_includes_relay_state_in_signature() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var relayState = "test-relay-state-value"; + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, relayState, signingCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&RelayState={Uri.EscapeDataString(relayState)}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.RelayState.ShouldBe(relayState); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_post_binding_signature_element_has_empty_reference() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var authNRequestXml = Build.AuthNRequestXml(); + var signedXml = SignAuthNRequestXmlWithEmptyReference(authNRequestXml, signingCert); + var encodedRequest = ConvertToBase64Encoded(signedXml); + var formData = new Dictionary { { "SAMLRequest", encodedRequest } }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCodes.Requester); + samlError.StatusMessage.ShouldBe("Invalid signature"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_post_binding_signature_reference_wrong_id() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var authNRequestXml = Build.AuthNRequestXml(); + var signedXml = SignAuthNRequestXml(authNRequestXml, signingCert); + signedXml = signedXml.Replace($"#{Fixture.Data.RequestId}", "#_bogus_id"); + var encodedRequest = ConvertToBase64Encoded(signedXml); + var formData = new Dictionary { { "SAMLRequest", encodedRequest } }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCodes.Requester); + samlError.StatusMessage.ShouldBe("Invalid signature"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_post_binding_signature_uses_exclusive_canonicalization() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + // SignAuthNRequestXml already uses ExcC14N, so this validates it works + var signedXml = SignAuthNRequestXml(Build.AuthNRequestXml(), signingCert); + var encodedRequest = ConvertToBase64Encoded(signedXml); + var formData = new Dictionary { { "SAMLRequest", encodedRequest } }; + var content = new FormUrlEncodedContent(formData); + + var result = await Fixture.Client.PostAsync("/saml/signin", content, _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_certificate_expired() + { + var expiredCert = CreateExpiredTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(expiredCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, expiredCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCodes.Responder); + samlError.StatusMessage.ShouldBe("No valid certificates configured for service provider"); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_signature_certificate_not_yet_valid() + { + var notYetValidCert = CreateNotYetValidTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(notYetValidCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, notYetValidCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var samlError = await ExtractSamlErrorFromPostAsync(result); + samlError.StatusCode.ShouldBe(SamlStatusCodes.Responder); + samlError.StatusMessage.ShouldBe("No valid certificates configured for service provider"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_signature_certificate_within_validity_period() + { + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + Fixture.ServiceProviders.Add(Build.SamlServiceProvider(signingCert, requireSignedAuthnRequests: true)); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + var (signature, sigAlg) = SignAuthNRequestRedirect(urlEncoded, null, signingCert); + + var result = await Fixture.Client.GetAsync( + $"/saml/signin?SAMLRequest={urlEncoded}&Signature={Uri.EscapeDataString(signature)}&SigAlg={Uri.EscapeDataString(sigAlg)}", _ct); + + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + } + + [Fact] + [Trait("Category", Category)] + public async Task auth_n_request_with_requested_authn_context_and_requirement_is_satisfied() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(requestedAuthnContext: requestedAuthnContext)); + + // First, initiate SAML signin to create state (use NonRedirectingClient for all requests to share cookies) + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + signinResult.StatusCode.ShouldBe(HttpStatusCode.Redirect); + + // Then sign in the user with authn context requirements + Fixture.UserMetRequestedAuthnContextRequirements = true; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim("saml:acr", "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + ], "Test")); + + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + // Act - complete the callback + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.AuthnStatement?.AuthnContextClassRef.ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"); + } + + [Fact] + [Trait("Category", Category)] + public async Task auth_n_request_with_requested_authn_context_and_requirement_is_not_satisfied() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(requestedAuthnContext: requestedAuthnContext)); + + // First, initiate SAML signin to create state (use NonRedirectingClient for all requests to share cookies) + var signinResult = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + signinResult.StatusCode.ShouldBe(HttpStatusCode.Redirect); + + // Then sign in the user with authn context requirements + Fixture.UserMetRequestedAuthnContextRequirements = false; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim("saml:acr", "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport") + ], "Test")); + + await Fixture.NonRedirectingClient.GetAsync("/__signin", _ct); + + // Act - complete the callback + var result = await Fixture.NonRedirectingClient.GetAsync("/saml/signin_callback", _ct); + + // Assert + var samlResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + samlResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + samlResponse.SubStatusCode.ShouldBe(SamlStatusCodes.NoAuthnContext); + samlResponse.Assertion.AuthnStatement?.AuthnContextClassRef.ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"); + } + + [Fact] + [Trait("Category", Category)] + public async Task auth_n_request_with_requested_authn_context_but_no_claim_returns_error() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(requestedAuthnContext: requestedAuthnContext)); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var samlResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + samlResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + samlResponse.SubStatusCode.ShouldBe(SamlStatusCodes.NoAuthnContext); + samlResponse.Assertion.AuthnStatement?.AuthnContextClassRef.ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified"); + } + + [Fact] + [Trait("Category", Category)] + public async Task auth_n_request_without_requested_authn_context_returns_authn_context_if_claim_is_present() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim("saml:acr", "urn:oasis:names:tc:SAML:2.0:ac:classes:X509") + ], "Test")); + + await Fixture.Client.GetAsync("/__signin", _ct); + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml()); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.AuthnStatement?.AuthnContextClassRef.ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:X509"); + } + + [Fact] + [Trait("Category", Category)] + public async Task auth_n_request_with_multiple_authn_context_class_refs_parsed_correctly() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + + await Fixture.InitializeAsync(); + + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + urn:oasis:names:tc:SAML:2.0:ac:classes:X509 + + """; + + var urlEncoded = await EncodeRequest(Build.AuthNRequestXml(requestedAuthnContext: requestedAuthnContext)); + + // Act + var result = await Fixture.NonRedirectingClient.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + result.StatusCode.ShouldBe(HttpStatusCode.Found); + + var parsedRequestedAuthnContext = await Fixture.NonRedirectingClient.GetFromJsonAsync("/__authentication-request", _ct); + + // Assert + parsedRequestedAuthnContext.ShouldNotBeNull(); + parsedRequestedAuthnContext.Comparison.ShouldBe(AuthnContextComparison.Better); + parsedRequestedAuthnContext.AuthnContextClassRefs.Count.ShouldBe(3); + parsedRequestedAuthnContext.AuthnContextClassRefs.ShouldBe(["urn:oasis:names:tc:SAML:2.0:ac:classes:Password", "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", "urn:oasis:names:tc:SAML:2.0:ac:classes:X509"]); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_name_id_format_is_supported() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(nameIdFormat: SamlConstants.NameIdentifierFormats.Persistent); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task error_when_name_id_format_is_not_supported() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var unsupportedFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos"; + var authnRequestXml = Build.AuthNRequestXml(nameIdFormat: unsupportedFormat); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var errorResponse = await ExtractSamlErrorFromPostAsync(result); + errorResponse.StatusCode.ShouldBe(SamlStatusCodes.Responder); + errorResponse.SubStatusCode.ShouldBe(SamlStatusCodes.InvalidNameIdPolicy); + errorResponse.StatusMessage.ShouldBe($"Requested NameID format '{unsupportedFormat}' is not supported by this IdP"); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_name_id_policy_element_present_without_format() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // NameIDPolicy with SPNameQualifier but no Format - should succeed + var authnRequestXml = Build.AuthNRequestXml(spNameQualifier: "https://custom.sp.com"); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task success_when_no_name_id_policy_element() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LoginUrl.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task response_uses_requested_name_id_format_from_policy() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id"), new Claim(JwtClaimTypes.Email, "test@test.com")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(nameIdFormat: SamlConstants.NameIdentifierFormats.EmailAddress); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe("test@test.com"); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.EmailAddress); + } + + [Fact] + [Trait("Category", Category)] + public async Task response_uses_sp_default_name_id_format_when_no_policy() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var persistentIdentifier = Guid.NewGuid().ToString(); + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id"), new Claim(ClaimTypes.NameIdentifier, persistentIdentifier)], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); // No NameIDPolicy + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe(persistentIdentifier); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Persistent); + } + + [Fact] + [Trait("Category", Category)] + public async Task response_uses_email_format_when_no_policy_and_no_sp_default() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = null; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(JwtClaimTypes.Email, "user@example.com") + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); // No NameIDPolicy + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Unspecified); + successResponse.Assertion.Subject?.NameId.ShouldBe("user-id"); + } + + [Fact] + [Trait("Category", Category)] + public async Task transient_format_generates_different_ids_per_request() + { + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml( + nameIdFormat: SamlConstants.NameIdentifierFormats.Transient); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // First request + var result1 = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + var response1 = await ExtractSamlSuccessFromPostAsync(result1, _ct); + var nameId1 = response1.Assertion.Subject?.NameId; + + // Second request with same parameters + var result2 = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + var response2 = await ExtractSamlSuccessFromPostAsync(result2, _ct); + var nameId2 = response2.Assertion.Subject?.NameId; + + // Verify both responses succeeded + response1.StatusCode.ShouldBe(SamlStatusCodes.Success); + response2.StatusCode.ShouldBe(SamlStatusCodes.Success); + + // Verify format is transient + response1.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Transient); + response2.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Transient); + + // Verify IDs are different (transient should be unique per request) + nameId1.ShouldNotBeNull(); + nameId2.ShouldNotBeNull(); + nameId1.ShouldNotBe(nameId2, "Transient NameIDs should be unique per authentication"); + } + + [Fact] + [Trait("Category", Category)] + public async Task persistent_format_uses_default_claim_type_from_service_provider_options() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var persistentId = "persistent-id-12345"; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(ClaimTypes.NameIdentifier, persistentId) // Default claim type + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); // No NameIDPolicy, uses SP default + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe(persistentId); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Persistent); + successResponse.Assertion.Subject?.SPNameQualifier.ShouldBe(Data.EntityId.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task persistent_format_uses_sp_specific_claim_type_override() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + sp.DefaultPersistentNameIdentifierClaimType = "custom_persistent_id"; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var spSpecificId = "sp-specific-id-67890"; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(ClaimTypes.NameIdentifier, "default-id"), + new Claim("custom_persistent_id", spSpecificId) // SP-specific claim + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe(spSpecificId); // Uses SP override, not default + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Persistent); + successResponse.Assertion.Subject?.SPNameQualifier.ShouldBe(Data.EntityId.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public async Task persistent_format_fails_when_claim_missing() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // User WITHOUT ClaimTypes.NameIdentifier claim + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(JwtClaimTypes.Email, "user@example.com") + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - if configured claim type cannot be found the request cannot be fulfilled + result.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + [Trait("Category", Category)] + public async Task persistent_format_fails_when_claim_value_is_empty() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // User with empty ClaimTypes.NameIdentifier + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(ClaimTypes.NameIdentifier, "") // Empty claim + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - if configured claim type cannot be found the request cannot be fulfilled + result.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + [Trait("Category", Category)] + public async Task persistent_format_sets_sp_name_qualifier_to_sp_entity_id() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var persistentId = "persistent-abc123"; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(ClaimTypes.NameIdentifier, persistentId) + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.SPNameQualifier.ShouldBe(Data.EntityId.ToString()); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Persistent); + } + + [Fact] + [Trait("Category", Category)] + public async Task persistent_format_works_with_custom_global_claim_type() + { + // Arrange + Fixture.ConfigureSamlOptions = options => + { + options.DefaultPersistentNameIdentifierClaimType = "app_persistent_id"; + }; + + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var customPersistentId = "global-custom-id-xyz"; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim("app_persistent_id", customPersistentId), // Custom global claim type + // Note: ClaimTypes.NameIdentifier NOT present + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe(customPersistentId); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.Persistent); + } + + [Fact] + [Trait("Category", Category)] + public async Task multiple_users_get_different_persistent_ids_same_sp() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.Persistent; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // User A + var userAPersistentId = "user-a-persistent-123"; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-a"), + new Claim(ClaimTypes.NameIdentifier, userAPersistentId) + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var resultA = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + var responseA = await ExtractSamlSuccessFromPostAsync(resultA, _ct); + + // User B (re-authenticate as different user) + var userBPersistentId = "user-b-persistent-456"; + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-b"), + new Claim(ClaimTypes.NameIdentifier, userBPersistentId) + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var resultB = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + var responseB = await ExtractSamlSuccessFromPostAsync(resultB, _ct); + + // Assert + responseA.StatusCode.ShouldBe(SamlStatusCodes.Success); + responseB.StatusCode.ShouldBe(SamlStatusCodes.Success); + + responseA.Assertion.Subject?.NameId.ShouldBe(userAPersistentId); + responseB.Assertion.Subject?.NameId.ShouldBe(userBPersistentId); + + // Verify IDs are distinct + responseA.Assertion.Subject?.NameId.ShouldNotBe(responseB.Assertion.Subject?.NameId, + "Different users should have different persistent identifiers"); + } + + [Fact] + [Trait("Category", Category)] + public async Task email_format_fails_when_claim_missing() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.EmailAddress; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // User WITHOUT ClaimTypes.NameIdentifier claim + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id") + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - if configured claim type cannot be found the request cannot be fulfilled + result.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + [Trait("Category", Category)] + public async Task email_format_fails_when_claim_value_is_empty() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.EmailAddress; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // User with empty ClaimTypes.NameIdentifier + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(ClaimTypes.Email, "") // Empty claim + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert - if configured claim type cannot be found the request cannot be fulfilled + result.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + } + + [Fact] + [Trait("Category", Category)] + public async Task email_format_returns_expected_value_when_claim_is_present() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.DefaultNameIdFormat = SamlConstants.NameIdentifierFormats.EmailAddress; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // User with empty ClaimTypes.NameIdentifier + Fixture.UserToSignIn = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, "user-id"), + new Claim(ClaimTypes.Email, "test@testing.com") + ], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var authnRequestXml = Build.AuthNRequestXml(); + var urlEncoded = await EncodeRequest(authnRequestXml); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={urlEncoded}", _ct); + + // Assert + var successResponse = await ExtractSamlSuccessFromPostAsync(result, _ct); + successResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + successResponse.Assertion.Subject?.NameId.ShouldBe("test@testing.com"); + successResponse.Assertion.Subject?.NameIdFormat.ShouldBe(SamlConstants.NameIdentifierFormats.EmailAddress); + } +} + diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutCallbackEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutCallbackEndpointTests.cs new file mode 100644 index 000000000..4c404b963 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutCallbackEndpointTests.cs @@ -0,0 +1,151 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Stores; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +public class SamlSingleLogoutCallbackEndpointTests +{ + private const string Category = "SAML single logout callback endpoint"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private SamlFixture Fixture = new(); + + private SamlData Data => Fixture.Data; + + private SamlDataBuilder Build => Fixture.Builder; + + [Fact] + [Trait("Category", Category)] + public async Task callback_with_post_method_should_return_method_not_allowed() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Act + var result = await Fixture.Client.PostAsync("/saml/logout_callback", new StringContent(""), _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.MethodNotAllowed); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_with_missing_logout_id_should_return_bad_request() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Act + var result = await Fixture.Client.GetAsync("/saml/logout_callback", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_with_invalid_logout_id_should_return_bad_request() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Act + var result = await Fixture.Client.GetAsync("/saml/logout_callback?logoutId=invalid", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_with_valid_logout_id_should_return_success_response() + { + // Arrange + var sp = Build.SamlServiceProvider(); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Store a logout message as if SP-initiated logout occurred + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session456", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_abc123", + SamlRelayState = null + }; + var messageStore = Fixture.Get>(); + var logoutId = await messageStore.WriteAsync(new Message(logoutMessage, DateTime.UtcNow), _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout_callback?logoutId={logoutId}", _ct); + + // Assert + var samlResponse = await SamlTestHelpers.ExtractSamlLogoutResponseFromPostAsync(result, _ct); + samlResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_should_include_relay_state_if_present() + { + // Arrange + var sp = Build.SamlServiceProvider(); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session456", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_abc123", + SamlRelayState = "mystate123" + }; + var messageStore = Fixture.Get>(); + var logoutId = await messageStore.WriteAsync(new Message(logoutMessage, DateTime.UtcNow), _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout_callback?logoutId={logoutId}", _ct); + + // Assert + var response = await SamlTestHelpers.ExtractSamlLogoutResponseFromPostAsync(result, _ct); + response.RelayState.ShouldBe(logoutMessage.SamlRelayState); + } + + [Fact] + [Trait("Category", Category)] + public async Task callback_with_disabled_service_provider_should_return_bad_request() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.Enabled = false; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session456", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_abc123" + }; + var messageStore = Fixture.Get>(); + var logoutId = await messageStore.WriteAsync(new Message(logoutMessage, DateTime.UtcNow), _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout_callback?logoutId={logoutId}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutEndpointTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutEndpointTests.cs new file mode 100644 index 000000000..270720512 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlSingleLogoutEndpointTests.cs @@ -0,0 +1,529 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text; +using System.Web; +using Duende.IdentityModel; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Microsoft.AspNetCore.Mvc; +using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +public class SamlSingleLogoutEndpointTests +{ + private const string Category = "SAML single logout endpoint"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private SamlFixture Fixture = new(); + + private SamlData Data => Fixture.Data; + + private SamlDataBuilder Build => Fixture.Builder; + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_no_saml_request_in_redirect_binding_should_return_bad_request() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + // Act + var result = await Fixture.Client.GetAsync("/saml/logout", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("Missing 'SAMLRequest' query parameter in SAML logout request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_post_binding_with_wrong_content_type_should_return_bad_request() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var logoutRequestXml = Build.LogoutRequestXml(); + var stringContent = new StringContent(logoutRequestXml, Encoding.UTF8, "application/xml"); + + // Act + var result = await Fixture.Client.PostAsync("/saml/logout", stringContent, _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("POST request does not have form content type for SAML logout request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_post_binding_with_missing_saml_request_should_return_bad_request() + { + // Arrange + Fixture.ServiceProviders.Add(Build.SamlServiceProvider()); + await Fixture.InitializeAsync(); + + var logoutRequestXml = Build.LogoutRequestXml(); + var encodedRequest = await EncodeRequest(logoutRequestXml, _ct); + var formData = new Dictionary + { + { "wrong_form_key", encodedRequest } + }; + var content = new FormUrlEncodedContent(formData); + + // Act + var result = await Fixture.Client.PostAsync("/saml/logout", content, _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe("Missing 'SAMLRequest' form parameter in SAML logout request"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_service_provider_not_found_should_return_bad_request() + { + // Arrange + await Fixture.InitializeAsync(); + + var issuer = "https://wrong-issuer.com"; + var logoutRequestXml = Build.LogoutRequestXml(issuer: issuer); + var urlEncoded = await EncodeRequest(logoutRequestXml, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"Service Provider '{issuer}' is not registered or is disabled"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_service_provider_is_disabled_should_return_bad_request() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.Enabled = false; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var logoutRequestXml = Build.LogoutRequestXml(issuer: sp.EntityId); + var urlEncoded = await EncodeRequest(logoutRequestXml, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"Service Provider '{sp.EntityId}' is not registered or is disabled"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_service_provider_has_no_single_logout_service_url_configured_should_return_error() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + sp.SingleLogoutServiceUrl = null; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Sign in a user first + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Url()}/saml/logout"), + sessionIndex: "session123"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"Service Provider '{sp.EntityId}' has no SingleLogoutServiceUrl configured"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_saml_version_in_logout_request_is_invalid_should_return_error_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Url()}/saml/logout"), + version: "1.0"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, _ct); + logoutResponse.StatusCode.ShouldBe(SamlStatusCodes.VersionMismatch); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_issue_instant_is_in_future_should_return_error_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var futureTime = Data.Now.AddMinutes(10); + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Url()}/saml/logout"), + issueInstant: futureTime, + sessionIndex: "session123"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, _ct); + logoutResponse.StatusCode.ShouldBe(SamlStatusCodes.Requester); + logoutResponse.StatusMessage.ShouldBe("Request IssueInstant is in the future"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_issue_instant_is_too_old_should_return_error_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var oldTime = Data.Now.AddMinutes(-10); + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Url()}/saml/logout"), + issueInstant: oldTime, + sessionIndex: "session123"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, _ct); + logoutResponse.StatusCode.ShouldBe(SamlStatusCodes.Requester); + logoutResponse.StatusMessage.ShouldBe("Request has expired (IssueInstant too old)"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_destination_does_not_match_endpoint_should_return_error_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri("https://wrong-destination.com/saml/logout"), + sessionIndex: "session123"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, _ct); + logoutResponse.StatusCode.ShouldBe(SamlStatusCodes.Requester); + logoutResponse.StatusMessage.ShouldBe($"Invalid destination. Expected '{Fixture.Url()}/saml/logout'"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_service_provider_has_no_signing_certificates_configured_should_return_bad_request() + { + // Arrange + var sp = Build.SamlServiceProvider(); + sp.SigningCertificates = []; + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Url()}/saml/logout"), + sessionIndex: "session123"); + var urlEncoded = await EncodeRequest(logoutRequestXml, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var problemDetails = await result.Content.ReadFromJsonAsync(_ct); + problemDetails.ShouldNotBeNull(); + problemDetails.Detail.ShouldBe($"Service Provider '{sp.EntityId}' has no signing certificates configured and has sent a SAML logout request which requires signature validation"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_request_without_signature_received_should_return_error_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Create a logout request without a signature + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Url()}/saml/logout"), + sessionIndex: "session123"); + var urlEncoded = await EncodeRequest(logoutRequestXml, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, _ct); + logoutResponse.StatusCode.ShouldBe(SamlStatusCodes.Requester); + logoutResponse.StatusMessage.ShouldBe("Missing signature parameter"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_not_on_or_after_is_in_past_should_return_error_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + var expiredTime = Data.Now.AddMinutes(-10); + var logoutRequestXml = Build.LogoutRequestXml( + notOnOrAfter: expiredTime, + sessionIndex: "session123"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, _ct); + logoutResponse.StatusCode.ShouldBe(SamlStatusCodes.Requester); + logoutResponse.StatusMessage.ShouldBe("Logout request expired (NotOnOrAfter is in the past)"); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_no_authenticated_session_should_return_success_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Don't sign in a user - no authenticated session + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Url()}/saml/logout"), + sessionIndex: "session123"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, _ct); + logoutResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_no_session_found_for_session_index_should_return_success_response() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + var anotherSp = Build.SamlServiceProvider(signingCertificate: signingCert); + sp.EntityId = "https://another-sp.com"; + Fixture.ServiceProviders.Add(anotherSp); + await Fixture.InitializeAsync(); + + // Sign in a user first + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Use a different service provider than what was established + var logoutRequestXml = Build.LogoutRequestXml( + issuer: anotherSp.EntityId, // Use a different SP so session will not be found + destination: new Uri($"{Fixture.Url()}/saml/logout")); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, _ct); + logoutResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_wrong_session_index_sent_should_return_success() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Sign in a user first + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Use a different session index than what was established + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Url()}/saml/logout"), + sessionIndex: "wrong-session-index"); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + var logoutResponse = await ExtractSamlLogoutResponseFromPostAsync(result, _ct); + logoutResponse.StatusCode.ShouldBe(SamlStatusCodes.Success); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_endpoint_when_request_is_valid_should_redirect_to_logout() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Sign in a user first + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Perform logout to get correct session index from the response + var sessionIndex = await PerformSigninAndExtractSessionIndex(sp); + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Url()}/saml/logout"), + sessionIndex: sessionIndex); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); + var requestUrl = result.RequestMessage?.RequestUri; + requestUrl.ShouldNotBeNull(); + requestUrl.AbsolutePath.ShouldBe(Fixture.LogoutUrl.ToString()); + var queryStringValues = HttpUtility.ParseQueryString(requestUrl.Query); + queryStringValues["logoutId"].ShouldNotBeNullOrWhiteSpace(); + } + + [Fact(Skip = "Endpoint is no longer responsible for logout as Host app logout page is now responsible. Return to this after propagated logout is complete and adjust/remove as appropriate")] + [Trait("Category", Category)] + public async Task logout_endpoint_when_logout_is_successful_should_terminate_user_session() + { + // Arrange + var signingCert = CreateTestSigningCertificate(Data.FakeTimeProvider); + var sp = Build.SamlServiceProvider(signingCertificate: signingCert); + Fixture.ServiceProviders.Add(sp); + await Fixture.InitializeAsync(); + + // Sign in a user first + Fixture.UserToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id")], "Test")); + await Fixture.Client.GetAsync("/__signin", _ct); + + // Ensure user can access protected resource + var initialProtectedResourceResult = await Fixture.Client.GetAsync("__protected-resource", _ct); + initialProtectedResourceResult.StatusCode.ShouldBe(HttpStatusCode.OK); + + var sessionIndex = await PerformSigninAndExtractSessionIndex(sp); + + var logoutRequestXml = Build.LogoutRequestXml( + destination: new Uri($"{Fixture.Url()}/saml/logout"), + sessionIndex: sessionIndex); + var urlEncoded = await EncodeAndSignRequest(logoutRequestXml, sp, _ct); + + // Act + var result = await Fixture.Client.GetAsync($"/saml/logout?SAMLRequest={urlEncoded}", _ct); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.OK); // Follows redirect + + // Verify user can no longer access protected resource and is redirected to login + var finalProtectedResourceResult = await Fixture.Client.GetAsync("__protected-resource", _ct); + finalProtectedResourceResult.StatusCode.ShouldBe(HttpStatusCode.OK); + finalProtectedResourceResult.RequestMessage?.RequestUri?.AbsoluteUri.ShouldStartWith($"{Fixture.Url()}{Fixture.LoginUrl.ToString()}"); + } + + private static async Task EncodeAndSignRequest( + string xml, + SamlServiceProvider sp, + Ct ct = default) + { + var encoded = await EncodeRequest(xml, ct); + + // Sign the request using the SP's certificate + var certificate = sp.SigningCertificates!.First(); + var (signature, sigAlg) = SignAuthNRequestRedirect(encoded, null, certificate); + + return $"{encoded}&SigAlg={Uri.EscapeDataString(sigAlg)}&Signature={Uri.EscapeDataString(signature)}"; + } + + private async Task PerformSigninAndExtractSessionIndex(SamlServiceProvider samlServiceProvider) + { + var signinRequest = Build.AuthNRequestXml(); + var encoded = await EncodeAndSignRequest(signinRequest, samlServiceProvider, _ct); + var signinResult = await Fixture.Client.GetAsync($"/saml/signin?SAMLRequest={encoded}", _ct); + var samlResult = await ExtractSamlSuccessFromPostAsync(signinResult, _ct); + if (string.IsNullOrWhiteSpace(samlResult.Assertion.AuthnStatement?.SessionIndex)) + { + throw new InvalidOperationException("SAMLResult did not have a valid session index"); + } + + return samlResult.Assertion.AuthnStatement.SessionIndex; + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlTestHelpers.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlTestHelpers.cs new file mode 100644 index 000000000..7d00b1767 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SamlTestHelpers.cs @@ -0,0 +1,868 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.IO.Compression; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography.Xml; +using System.Text; +using System.Web; +using System.Xml; +using Microsoft.Net.Http.Headers; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +internal static class SamlTestHelpers +{ + public static async Task EncodeRequest(string authenticationRequest, Ct ct = default) + { + var bytes = Encoding.UTF8.GetBytes(authenticationRequest); + using var outputStream = new MemoryStream(); + await using (var deflateStream = new DeflateStream(outputStream, CompressionMode.Compress, leaveOpen: true)) + { + await deflateStream.WriteAsync(bytes, 0, bytes.Length, ct); + } + + var compressedBytes = outputStream.ToArray(); + var base64 = Convert.ToBase64String(compressedBytes); + var urlEncoded = Uri.EscapeDataString(base64); + return urlEncoded; + } + + public static string ConvertToBase64Encoded(string authenticationRequest) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(authenticationRequest)); + + /// + /// Extracts SAML error response from an HTTP-POST binding auto-submit form. + /// + public static async Task ExtractSamlErrorFromPostAsync(HttpResponseMessage response, Ct ct = default) + { + var (responseXml, relayState, acsUrl) = await ExtractSamlResponse(response, ct); + var (samlpNs, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var baseData = ParseCommonResponseElements(responseElement, samlNs, samlpNs, relayState, acsUrl); + + return new SamlErrorResponseData + { + ResponseId = baseData.ResponseId, + InResponseTo = baseData.InResponseTo, + Destination = baseData.Destination, + IssueInstant = baseData.IssueInstant, + Issuer = baseData.Issuer, + StatusCode = baseData.StatusCode, + StatusMessage = baseData.StatusMessage, + SubStatusCode = baseData.SubStatusCode, + RelayState = baseData.RelayState, + AssertionConsumerServiceUrl = baseData.AssertionConsumerServiceUrl + }; + } + + public static async Task ExtractSamlLogoutResponseFromPostAsync(HttpResponseMessage response, Ct ct = default) + { + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var (responseXml, relayState, acsUrl) = await ExtractSamlResponse(response, ct); + var (samlpNs, samlNs, logoutResponseElement) = ParseSamlLogoutResponseXml(responseXml); + var baseData = ParseCommonResponseElements(logoutResponseElement, samlNs, samlpNs, relayState, acsUrl); + + return new SamlLogoutResponseData + { + ResponseId = baseData.ResponseId, + InResponseTo = baseData.InResponseTo, + Destination = baseData.Destination, + IssueInstant = baseData.IssueInstant, + Issuer = baseData.Issuer, + StatusCode = baseData.StatusCode, + StatusMessage = baseData.StatusMessage, + SubStatusCode = baseData.SubStatusCode, + RelayState = baseData.RelayState, + AssertionConsumerServiceUrl = baseData.AssertionConsumerServiceUrl + }; + } + + public static async Task ExtractSamlSuccessFromPostAsync(HttpResponseMessage response, Ct ct = default) + { + var (responseXml, relayState, acsUrl) = await ExtractSamlResponse(response, ct); + var (samlpNs, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var baseData = ParseCommonResponseElements(responseElement, samlNs, samlpNs, relayState, acsUrl); + + var assertion = ParseAssertion(responseElement, samlNs); + + return new SamlSuccessResponseData + { + ResponseId = baseData.ResponseId, + InResponseTo = baseData.InResponseTo, + Destination = baseData.Destination, + IssueInstant = baseData.IssueInstant, + Issuer = baseData.Issuer, + StatusCode = baseData.StatusCode, + StatusMessage = baseData.StatusMessage, + SubStatusCode = baseData.SubStatusCode, + RelayState = baseData.RelayState, + AssertionConsumerServiceUrl = baseData.AssertionConsumerServiceUrl, + Assertion = assertion + }; + } + + public static async Task<(string responseXml, string? relayState, string acsUrl)> ExtractSamlResponse(HttpResponseMessage response, Ct ct = default) + { + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html"); + + var html = await response.Content.ReadAsStringAsync(ct); + + // Extract SAMLResponse from hidden input field + var samlResponseMatch = System.Text.RegularExpressions.Regex.Match( + html, + @"]+name=""SAMLResponse""[^>]+value=""([^""]+)""", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + samlResponseMatch.Success.ShouldBeTrue("SAMLResponse input field not found in HTML"); + var encodedResponse = samlResponseMatch.Groups[1].Value; + + // Extract RelayState if present + string? relayState = null; + var relayStateMatch = System.Text.RegularExpressions.Regex.Match( + html, + @"]+name=""RelayState""[^>]+value=""([^""]+)""", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (relayStateMatch.Success) + { + relayState = HttpUtility.HtmlDecode(relayStateMatch.Groups[1].Value); + } + + // Extract form action (ACS URL) + var actionMatch = System.Text.RegularExpressions.Regex.Match( + html, + @"]+action=""([^""]+)""", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + actionMatch.Success.ShouldBeTrue("Form action not found in HTML"); + var acsUrl = HttpUtility.HtmlDecode(actionMatch.Groups[1].Value); + + // Decode the SAML response + var decodedBytes = Convert.FromBase64String(HttpUtility.HtmlDecode(encodedResponse)); + var responseXml = Encoding.UTF8.GetString(decodedBytes); + + return (responseXml, relayState, acsUrl); + } + + public static (System.Xml.Linq.XNamespace samlpNs, System.Xml.Linq.XNamespace samlNs, System.Xml.Linq.XElement responseElement) ParseSamlResponseXml(string responseXml) + { + var doc = System.Xml.Linq.XDocument.Parse(responseXml); + var samlpNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:protocol"); + var samlNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:assertion"); + + var responseElement = doc.Root; + responseElement.ShouldNotBeNull(); + responseElement.Name.ShouldBe(samlpNs + "Response"); + + return (samlpNs, samlNs, responseElement); + } + + public static (System.Xml.Linq.XNamespace samlpNs, System.Xml.Linq.XNamespace samlNs, System.Xml.Linq.XElement logoutResponseElement) ParseSamlLogoutResponseXml(string responseXml) + { + var doc = System.Xml.Linq.XDocument.Parse(responseXml); + var samlpNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:protocol"); + var samlNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:assertion"); + + var logoutResponseElement = doc.Root; + logoutResponseElement.ShouldNotBeNull(); + logoutResponseElement.Name.ShouldBe(samlpNs + "LogoutResponse"); + + return (samlpNs, samlNs, logoutResponseElement); + } + + public static SamlResponseBase ParseCommonResponseElements( + System.Xml.Linq.XElement responseElement, + System.Xml.Linq.XNamespace samlNs, + System.Xml.Linq.XNamespace samlpNs, + string? relayState, + string acsUrl) + { + var responseId = responseElement.Attribute("ID")?.Value; + var inResponseTo = responseElement.Attribute("InResponseTo")?.Value; + var destination = responseElement.Attribute("Destination")?.Value; + var issueInstant = responseElement.Attribute("IssueInstant")?.Value; + var issuer = responseElement.Element(samlNs + "Issuer")?.Value; + + var statusElement = responseElement.Element(samlpNs + "Status"); + statusElement.ShouldNotBeNull(); + + var statusCodeElement = statusElement.Element(samlpNs + "StatusCode"); + statusCodeElement.ShouldNotBeNull(); + var statusCode = statusCodeElement.Attribute("Value")?.Value; + var statusMessage = statusElement.Element(samlpNs + "StatusMessage")?.Value; + + var subStatusCodeElement = statusCodeElement.Element(samlpNs + "StatusCode"); + var subStatusCode = subStatusCodeElement?.Attribute("Value")?.Value; + + return new SamlResponseBase + { + ResponseId = responseId, + InResponseTo = inResponseTo, + Destination = destination, + IssueInstant = issueInstant, + Issuer = issuer, + StatusCode = statusCode, + StatusMessage = statusMessage, + SubStatusCode = subStatusCode, + RelayState = relayState, + AssertionConsumerServiceUrl = acsUrl + }; + } + + public static Assertion ParseAssertion(System.Xml.Linq.XElement responseElement, System.Xml.Linq.XNamespace samlNs) + { + var assertionElement = responseElement.Element(samlNs + "Assertion"); + assertionElement.ShouldNotBeNull(); + + var assertionId = assertionElement.Attribute("ID")?.Value; + var assertionVersion = assertionElement.Attribute("Version")?.Value; + var assertionIssueInstant = assertionElement.Attribute("IssueInstant")?.Value; + var assertionIssuer = assertionElement.Element(samlNs + "Issuer")?.Value; + + var subjectElement = assertionElement.Element(samlNs + "Subject"); + Subject? subject = null; + if (subjectElement != null) + { + var nameIdElement = subjectElement.Element(samlNs + "NameID"); + var subjectConfirmationElement = subjectElement.Element(samlNs + "SubjectConfirmation"); + + SubjectConfirmation? subjectConfirmation = null; + if (subjectConfirmationElement != null) + { + var subjectConfirmationDataElement = subjectConfirmationElement.Element(samlNs + "SubjectConfirmationData"); + SubjectConfirmationData? subjectConfirmationData = null; + if (subjectConfirmationDataElement != null) + { + subjectConfirmationData = new SubjectConfirmationData + { + NotOnOrAfter = subjectConfirmationDataElement.Attribute("NotOnOrAfter")?.Value, + Recipient = subjectConfirmationDataElement.Attribute("Recipient")?.Value, + InResponseTo = subjectConfirmationDataElement.Attribute("InResponseTo")?.Value + }; + } + + subjectConfirmation = new SubjectConfirmation + { + Method = subjectConfirmationElement.Attribute("Method")?.Value, + SubjectConfirmationData = subjectConfirmationData + }; + } + + subject = new Subject + { + NameId = nameIdElement?.Value, + NameIdFormat = nameIdElement?.Attribute("Format")?.Value, + SPNameQualifier = nameIdElement?.Attribute("SPNameQualifier")?.Value, + SubjectConfirmation = subjectConfirmation + }; + } + + var conditionsElement = assertionElement.Element(samlNs + "Conditions"); + Conditions? conditions = null; + if (conditionsElement != null) + { + var audienceRestrictionElement = conditionsElement.Element(samlNs + "AudienceRestriction"); + var audienceElement = audienceRestrictionElement?.Element(samlNs + "Audience"); + + conditions = new Conditions + { + NotBefore = conditionsElement.Attribute("NotBefore")?.Value, + NotOnOrAfter = conditionsElement.Attribute("NotOnOrAfter")?.Value, + Audience = audienceElement?.Value + }; + } + + var authnStatementElement = assertionElement.Element(samlNs + "AuthnStatement"); + AuthnStatement? authnStatement = null; + if (authnStatementElement != null) + { + var authnContextElement = authnStatementElement.Element(samlNs + "AuthnContext"); + var authnContextClassRefElement = authnContextElement?.Element(samlNs + "AuthnContextClassRef"); + + authnStatement = new AuthnStatement + { + AuthnInstant = authnStatementElement.Attribute("AuthnInstant")?.Value, + SessionIndex = authnStatementElement.Attribute("SessionIndex")?.Value, + AuthnContextClassRef = authnContextClassRefElement?.Value + }; + } + + var attributeStatementElement = assertionElement.Element(samlNs + "AttributeStatement"); + List? attributes = null; + if (attributeStatementElement != null) + { + attributes = attributeStatementElement.Elements(samlNs + "Attribute") + .Select(attr => + { + var attributeValues = attr.Elements(samlNs + "AttributeValue") + .Select(av => av.Value) + .ToList(); + + return new SamlAttribute + { + Name = attr.Attribute("Name")?.Value, + NameFormat = attr.Attribute("NameFormat")?.Value, + FriendlyName = attr.Attribute("FriendlyName")?.Value, + Value = attributeValues.FirstOrDefault(), // For backward compatibility + Values = attributeValues + }; + }) + .ToList(); + } + + return new Assertion + { + Id = assertionId, + Version = assertionVersion, + IssueInstant = assertionIssueInstant, + Issuer = assertionIssuer, + Subject = subject, + Conditions = conditions, + AuthnStatement = authnStatement, + Attributes = attributes + }; + } + + public record SamlResponseBase + { + public string? ResponseId { get; init; } + public string? InResponseTo { get; init; } + public string? Destination { get; init; } + public string? IssueInstant { get; init; } + public string? Issuer { get; init; } + public string? StatusCode { get; init; } + public string? StatusMessage { get; init; } + public string? SubStatusCode { get; init; } + public string? RelayState { get; init; } + public string? AssertionConsumerServiceUrl { get; init; } + } + + public record SamlErrorResponseData : SamlResponseBase + { + } + + public record SamlSuccessResponseData : SamlResponseBase + { + public required Assertion Assertion { get; init; } + } + + public record Assertion + { + public string? Id { get; init; } + public string? Version { get; init; } + public string? IssueInstant { get; init; } + public string? Issuer { get; init; } + public Subject? Subject { get; init; } + public Conditions? Conditions { get; init; } + public AuthnStatement? AuthnStatement { get; init; } + public List? Attributes { get; init; } + } + + public record Subject + { + public string? NameId { get; init; } + public string? NameIdFormat { get; init; } + public string? SPNameQualifier { get; init; } + public SubjectConfirmation? SubjectConfirmation { get; init; } + } + + public record SubjectConfirmation + { + public string? Method { get; init; } + public SubjectConfirmationData? SubjectConfirmationData { get; init; } + } + + public record SubjectConfirmationData + { + public string? NotOnOrAfter { get; init; } + public string? Recipient { get; init; } + public string? InResponseTo { get; init; } + } + + public record Conditions + { + public string? NotBefore { get; init; } + public string? NotOnOrAfter { get; init; } + public string? Audience { get; init; } + } + + public record AuthnStatement + { + public string? AuthnInstant { get; init; } + public string? SessionIndex { get; init; } + public string? AuthnContextClassRef { get; init; } + } + + public record SamlAttribute + { + public string? Name { get; init; } + public string? NameFormat { get; init; } + public string? FriendlyName { get; init; } + public string? Value { get; init; } + public List Values { get; init; } = new(); + } + + public static void VerifySignaturePresence(string responseXml, bool expectResponseSignature, bool expectAssertionSignature) + { + var hasResponseSig = HasResponseSignature(responseXml); + var hasAssertionSig = HasAssertionSignature(responseXml); + + if (expectResponseSignature) + { + hasResponseSig.ShouldBeTrue("Expected Response to have a Signature element"); + } + else + { + hasResponseSig.ShouldBeFalse("Expected Response to NOT have a Signature element"); + } + + if (expectAssertionSignature) + { + hasAssertionSig.ShouldBeTrue("Expected Assertion to have a Signature element"); + } + else + { + hasAssertionSig.ShouldBeFalse("Expected Assertion to NOT have a Signature element"); + } + } + + public static void VerifySignaturePositionAfterIssuer(System.Xml.Linq.XElement parentElement) + { + var samlNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:SAML:2.0:assertion"); + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + + var issuerElement = parentElement.Element(samlNs + "Issuer"); + var signatureElement = parentElement.Element(dsNs + "Signature"); + + issuerElement.ShouldNotBeNull("Parent element must have an Issuer"); + signatureElement.ShouldNotBeNull("Parent element must have a Signature"); + + // Check that Signature comes after Issuer in document order + var elements = parentElement.Elements().ToList(); + var issuerIndex = elements.IndexOf(issuerElement!); + var signatureIndex = elements.IndexOf(signatureElement!); + + signatureIndex.ShouldBeGreaterThan(issuerIndex, + "Signature element must appear after Issuer element per SAML specification"); + } + + public static System.Xml.Linq.XElement? ExtractSignatureElement(System.Xml.Linq.XElement parentElement) + { + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + return parentElement.Element(dsNs + "Signature"); + } + + public static string? GetSignatureReferenceUri(System.Xml.Linq.XElement signatureElement) + { + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + var referenceElement = signatureElement + .Element(dsNs + "SignedInfo") + ?.Element(dsNs + "Reference"); + + return referenceElement?.Attribute("URI")?.Value; + } + + public static SignatureInfo ParseSignatureInfo(System.Xml.Linq.XElement signatureElement) + { + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + + var signedInfo = signatureElement.Element(dsNs + "SignedInfo"); + signedInfo.ShouldNotBeNull("Signature must have SignedInfo element"); + + var canonicalizationMethod = signedInfo! + .Element(dsNs + "CanonicalizationMethod") + ?.Attribute("Algorithm")?.Value; + + var signatureMethod = signedInfo + .Element(dsNs + "SignatureMethod") + ?.Attribute("Algorithm")?.Value; + + var reference = signedInfo.Element(dsNs + "Reference"); + var referenceUri = reference?.Attribute("URI")?.Value; + + var digestMethod = reference? + .Element(dsNs + "DigestMethod") + ?.Attribute("Algorithm")?.Value; + + return new SignatureInfo + { + CanonicalizationMethod = canonicalizationMethod, + SignatureMethod = signatureMethod, + ReferenceUri = referenceUri, + DigestMethod = digestMethod + }; + } + + private static bool HasResponseSignature(string responseXml) + { + var (_, _, responseElement) = ParseSamlResponseXml(responseXml); + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + var signatureElement = responseElement.Element(dsNs + "Signature"); + return signatureElement != null; + } + + private static bool HasAssertionSignature(string responseXml) + { + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var dsNs = System.Xml.Linq.XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + + var assertionElement = responseElement.Element(samlNs + "Assertion"); + if (assertionElement == null) + { + return false; + } + + var signatureElement = assertionElement.Element(dsNs + "Signature"); + return signatureElement != null; + } + + public record SignatureInfo + { + public string? CanonicalizationMethod { get; init; } + public string? SignatureMethod { get; init; } + public string? ReferenceUri { get; init; } + public string? DigestMethod { get; init; } + } + + public static string? ExtractStateIdFromCookie(HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("Set-Cookie", out var setCookies)) + { + return null; + } + + if (!CookieHeaderValue.TryParseList(setCookies.ToList(), out var cookieHeaderValues)) + { + return null; + } + + var targetCookie = cookieHeaderValues.FirstOrDefault(cookie => cookie.Name == "__Host-idsrv.SamlSigninState"); + + return targetCookie?.Value.ToString(); + } + + public static X509Certificate2 CreateTestSigningCertificate(TimeProvider timeProvider, string subject = "CN=Test SP Signing Certificate") + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true)); + + var certificate = request.CreateSelfSigned( + timeProvider.GetUtcNow().AddDays(-1), + timeProvider.GetUtcNow().AddYears(10)); + + return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null, X509KeyStorageFlags.Exportable); + } + + public static string SignAuthNRequestXml(string authNRequestXml, X509Certificate2 certificate) + { + var doc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null }; + doc.LoadXml(authNRequestXml); + + var signedXml = new SignedXml(doc) { SigningKey = certificate.GetRSAPrivateKey() }; + + var reference = new Reference { Uri = "#" + doc.DocumentElement!.GetAttribute("ID") }; + reference.AddTransform(new XmlDsigEnvelopedSignatureTransform()); + reference.AddTransform(new XmlDsigExcC14NTransform()); + signedXml.AddReference(reference); + + signedXml.KeyInfo = new KeyInfo(); + signedXml.KeyInfo.AddClause(new KeyInfoX509Data(certificate)); + + signedXml.ComputeSignature(); + + var signatureElement = signedXml.GetXml(); + + // Insert signature after Issuer element per SAML spec + var issuerElement = doc.DocumentElement!.GetElementsByTagName("Issuer", "urn:oasis:names:tc:SAML:2.0:assertion")[0]; + doc.DocumentElement.InsertAfter(signatureElement, issuerElement); + + return doc.OuterXml; + } + + public static (string signature, string sigAlg) SignAuthNRequestRedirect( + string samlRequest, + string? relayState, + X509Certificate2 certificate, + string algorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256") + { + // Build the query string to sign (order matters!) + var queryToSign = $"SAMLRequest={samlRequest}"; + + if (!string.IsNullOrEmpty(relayState)) + { + queryToSign += $"&RelayState={Uri.EscapeDataString(relayState)}"; + } + + queryToSign += $"&SigAlg={Uri.EscapeDataString(algorithm)}"; + + var bytesToSign = Encoding.UTF8.GetBytes(queryToSign); + + using var rsa = certificate.GetRSAPrivateKey(); + var hashAlgorithm = algorithm.Contains("sha512") ? HashAlgorithmName.SHA512 : HashAlgorithmName.SHA256; + var signatureBytes = rsa!.SignData(bytesToSign, hashAlgorithm, RSASignaturePadding.Pkcs1); + + var signature = Convert.ToBase64String(signatureBytes); + + return (signature, algorithm); + } + + public static X509Certificate2 CreateExpiredTestSigningCertificate(TimeProvider timeProvider, string subject = "CN=Expired Test SP Certificate") + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true)); + + // Create certificate that expired yesterday + var certificate = request.CreateSelfSigned( + timeProvider.GetUtcNow().AddYears(-2), + timeProvider.GetUtcNow().AddDays(-1)); + + return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null, X509KeyStorageFlags.Exportable); + } + + public static X509Certificate2 CreateNotYetValidTestSigningCertificate(TimeProvider timeProvider, string subject = "CN=Future Test SP Certificate") + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true)); + + // Create certificate that won't be valid until tomorrow + var certificate = request.CreateSelfSigned( + timeProvider.GetUtcNow().AddDays(1), + timeProvider.GetUtcNow().AddYears(10)); + + return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null, X509KeyStorageFlags.Exportable); + } + + public static string SignAuthNRequestXmlWithEmptyReference(string authNRequestXml, X509Certificate2 certificate) + { + var doc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null }; + doc.LoadXml(authNRequestXml); + + var signedXml = new SignedXml(doc) { SigningKey = certificate.GetRSAPrivateKey() }; + + // Create reference with empty URI - this should fail validation + var reference = new Reference { Uri = "" }; + reference.AddTransform(new XmlDsigEnvelopedSignatureTransform()); + reference.AddTransform(new XmlDsigExcC14NTransform()); + signedXml.AddReference(reference); + + signedXml.KeyInfo = new KeyInfo(); + signedXml.KeyInfo.AddClause(new KeyInfoX509Data(certificate)); + + signedXml.ComputeSignature(); + + var signatureElement = signedXml.GetXml(); + + // Insert signature after Issuer element per SAML spec + var issuerElement = doc.DocumentElement!.GetElementsByTagName("Issuer", "urn:oasis:names:tc:SAML:2.0:assertion")[0]; + doc.DocumentElement.InsertAfter(signatureElement, issuerElement); + + return doc.OuterXml; + } + + public static void ValidateEncryptedStructure(System.Xml.Linq.XElement response) + { + ArgumentNullException.ThrowIfNull(response); + + var samlNs = System.Xml.Linq.XNamespace.Get( + "urn:oasis:names:tc:SAML:2.0:assertion"); + var encNs = System.Xml.Linq.XNamespace.Get( + "http://www.w3.org/2001/04/xmlenc#"); + + // Verify present + var encAssertion = response.Descendants(samlNs + "EncryptedAssertion") + .FirstOrDefault(); + encAssertion.ShouldNotBeNull( + "Response should contain element"); + + // Verify present + var encData = encAssertion.Descendants(encNs + "EncryptedData") + .FirstOrDefault(); + encData.ShouldNotBeNull( + " should contain element"); + + // Verify present + var encKey = encAssertion.Descendants(encNs + "EncryptedKey") + .FirstOrDefault(); + encKey.ShouldNotBeNull( + " should contain element"); + } + + public static bool HasEncryptedAssertion(string responseXml) + { + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var encryptedAssertion = responseElement.Element(samlNs + "EncryptedAssertion"); + return encryptedAssertion != null; + } + + public static bool HasPlainAssertion(string responseXml) + { + var (_, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var assertion = responseElement.Element(samlNs + "Assertion"); + return assertion != null; + } + + public static System.Xml.Linq.XElement DecryptAssertion( + System.Xml.Linq.XElement encryptedAssertion, + X509Certificate2 decryptionCertificate) + { + ArgumentNullException.ThrowIfNull(encryptedAssertion); + ArgumentNullException.ThrowIfNull(decryptionCertificate); + + using var privateKey = decryptionCertificate.GetRSAPrivateKey(); + if (privateKey == null) + { + throw new CryptographicException("Certificate does not contain an RSA private key"); + } + + // Convert to XmlDocument for EncryptedXml API + var xmlDoc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null }; + using (var reader = encryptedAssertion.CreateReader()) + { + xmlDoc.Load(reader); + } + + var nsManager = new XmlNamespaceManager(xmlDoc.NameTable); + nsManager.AddNamespace("xenc", "http://www.w3.org/2001/04/xmlenc#"); + + var encryptedDataElement = xmlDoc.SelectSingleNode("//xenc:EncryptedData", nsManager) as XmlElement; + if (encryptedDataElement == null) + { + throw new InvalidOperationException("No EncryptedData element found"); + } + + var encryptedData = new EncryptedData(); + encryptedData.LoadXml(encryptedDataElement); + + // The encryption was done with EncryptedXml.Encrypt(element, certificate) + // which embeds an EncryptedKey with the certificate info + // We need to decrypt the key first, then decrypt the data + + // Find and decrypt the EncryptedKey + var encryptedKeyElement = xmlDoc.SelectSingleNode("//xenc:EncryptedKey", nsManager) as XmlElement; + if (encryptedKeyElement == null) + { + throw new InvalidOperationException("No EncryptedKey element found"); + } + + var encryptedKey = new EncryptedKey(); + encryptedKey.LoadXml(encryptedKeyElement); + if (encryptedKey.CipherData.CipherValue == null) + { + throw new InvalidOperationException("No CipherValue found in encrypted key element"); + } + + // Decrypt the session key using our RSA private key + byte[] sessionKey; + + // The Encrypt method uses RSA-OAEP by default + if (encryptedKey.EncryptionMethod?.KeyAlgorithm == EncryptedXml.XmlEncRSAOAEPUrl) + { + sessionKey = privateKey.Decrypt(encryptedKey.CipherData.CipherValue, RSAEncryptionPadding.OaepSHA1); + } + else if (encryptedKey.EncryptionMethod?.KeyAlgorithm == EncryptedXml.XmlEncRSA15Url) + { + sessionKey = privateKey.Decrypt(encryptedKey.CipherData.CipherValue, RSAEncryptionPadding.Pkcs1); + } + else + { + throw new CryptographicException($"Unsupported key encryption algorithm: {encryptedKey.EncryptionMethod?.KeyAlgorithm}"); + } + + // Now decrypt the data using the session key + var encryptedXml = new EncryptedXml(); + byte[] decryptedBytes; + + // Determine the symmetric algorithm used + var algorithm = encryptedData.EncryptionMethod?.KeyAlgorithm; + if (string.IsNullOrEmpty(algorithm)) + { + throw new CryptographicException("No encryption algorithm specified"); + } + + // Create the appropriate symmetric algorithm + SymmetricAlgorithm? symmetricAlgorithm = algorithm switch + { + EncryptedXml.XmlEncAES256Url => Aes.Create(), + EncryptedXml.XmlEncAES192Url => Aes.Create(), + EncryptedXml.XmlEncAES128Url => Aes.Create(), + EncryptedXml.XmlEncTripleDESUrl => TripleDES.Create(), + _ => throw new CryptographicException($"Unsupported encryption algorithm: {algorithm}") + }; + + if (symmetricAlgorithm == null) + { + throw new CryptographicException("Failed to create symmetric algorithm"); + } + + using (symmetricAlgorithm) + { + symmetricAlgorithm.Key = sessionKey; + + // Decrypt the data + decryptedBytes = encryptedXml.DecryptData(encryptedData, symmetricAlgorithm); + } + + // Convert to string and parse + var decryptedXml = System.Text.Encoding.UTF8.GetString(decryptedBytes); + return System.Xml.Linq.XElement.Parse(decryptedXml); + } + + public static async Task ExtractAndDecryptSamlSuccessFromPostAsync( + HttpResponseMessage response, + X509Certificate2 decryptionCertificate, + Ct ct = default) + { + var (responseXml, relayState, acsUrl) = await ExtractSamlResponse(response, ct); + var (samlpNs, samlNs, responseElement) = ParseSamlResponseXml(responseXml); + var baseData = ParseCommonResponseElements(responseElement, samlNs, samlpNs, relayState, acsUrl); + + // Get the EncryptedAssertion element + var encryptedAssertion = responseElement.Element(samlNs + "EncryptedAssertion"); + if (encryptedAssertion == null) + { + throw new InvalidOperationException("Response does not contain an EncryptedAssertion element"); + } + + // Decrypt it - this returns the Assertion element + var decryptedAssertion = DecryptAssertion(encryptedAssertion, decryptionCertificate); + + // Create a temporary container to hold the decrypted assertion for parsing + var tempResponse = new System.Xml.Linq.XElement(samlpNs + "Response", + new System.Xml.Linq.XAttribute("ID", "_temp"), + new System.Xml.Linq.XAttribute("Version", "2.0"), + decryptedAssertion); + + // Parse the decrypted assertion + var assertion = ParseAssertion(tempResponse, samlNs); + + return new SamlSuccessResponseData + { + ResponseId = baseData.ResponseId, + InResponseTo = baseData.InResponseTo, + Destination = baseData.Destination, + IssueInstant = baseData.IssueInstant, + Issuer = baseData.Issuer, + StatusCode = baseData.StatusCode, + StatusMessage = baseData.StatusMessage, + SubStatusCode = baseData.SubStatusCode, + RelayState = baseData.RelayState, + AssertionConsumerServiceUrl = baseData.AssertionConsumerServiceUrl, + Assertion = assertion + }; + } + + public record SamlLogoutResponseData : SamlResponseBase + { + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSamlTestFixture.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSamlTestFixture.cs new file mode 100644 index 000000000..5897de5dd --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSamlTestFixture.cs @@ -0,0 +1,202 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; +using Duende.IdentityModel; +using Duende.IdentityServer.IntegrationTests.Common; +using Duende.IdentityServer.IntegrationTests.TestFramework; +using Duende.IdentityServer.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Sustainsys.Saml2.AspNetCore2; +using IdentityProvider = Sustainsys.Saml2.IdentityProvider; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +internal class SustainSysSamlTestFixture(ITestOutputHelper output) : IAsyncLifetime +{ + private readonly Ct _ct = TestContext.Current.CancellationToken; + + public KestrelTestHost? IdpHost; + public KestrelTestHost? SpHost; + public HttpClient? BrowserClient; + public X509Certificate2? SigningCertificate { get; private set; } + + private readonly List _serviceProviders = []; + private ClaimsPrincipal? _userToSignIn; + private bool _shouldGenerateSigningCertificate; + private bool _shouldRequireEncryptedAssertions; + + public async Task LoginUserAtIdentityProvider() + { + _userToSignIn = + new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, "user-id"), new Claim("name", "Test User"), new Claim(JwtClaimTypes.AuthenticationMethod, "urn:oasis:names:tc:SAML:2.0:ac:classes:Password")], "Test")); + await BrowserClient!.GetAsync($"{IdpHost!.Uri()}/__signin", _ct); + } + + public void GenerateSigningCertificate() => + // We cannot create this now because we need to defer creating it until after we've set + // the fake time provider because the SustainSys library does not rely on TimeProvider + // and we need to use current times + _shouldGenerateSigningCertificate = true; + + public void RequireEncryptedAssertions() => _shouldRequireEncryptedAssertions = true; + + public async ValueTask InitializeAsync() + { + // need to use current time because the SustainSys library does not rely on an abstraction such + // as TimeProvider and times need to be current + var fakeTimeProvider = new FakeTimeProvider(DateTime.UtcNow); + + // Generate certificates before initialization if needed + X509Certificate2? signingCertificate = null; + X509Certificate2? publicCertificate = null; + if (_shouldGenerateSigningCertificate) + { + signingCertificate = SamlTestHelpers.CreateTestSigningCertificate(fakeTimeProvider); + SigningCertificate = signingCertificate; // Expose for tests + publicCertificate = X509CertificateLoader.LoadCertificate(signingCertificate.Export(X509ContentType.Cert)); + } + + await InitializeIdentityProvider(fakeTimeProvider); + + await InitializeServiceProvider(IdpHost!.Uri(), signingCertificate); + + _serviceProviders.Add(new SamlServiceProvider + { + EntityId = "https://localhost:5001/Saml2", + DisplayName = "Test Service Provider", + Enabled = true, + AssertionConsumerServiceUrls = [new Uri($"{SpHost!.Uri()}/Saml2/Acs")], + SigningBehavior = SamlSigningBehavior.SignAssertion, + RequireSignedAuthnRequests = publicCertificate != null, + SigningCertificates = publicCertificate == null ? null : new[] { publicCertificate }, + EncryptionCertificates = publicCertificate == null ? null : new[] { publicCertificate }, + EncryptAssertions = _shouldRequireEncryptedAssertions + }); + + BrowserClient = SpHost.CreateClient(); + } + + private async Task InitializeIdentityProvider(FakeTimeProvider fakeTimeProvider) + { + var selfSignedCertificate = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(SamlFixture.StableSigningCert), null); + + IdpHost = await KestrelTestHost.Create(output, + services => + { + services.AddSingleton(fakeTimeProvider); + services.AddSingleton(sp => new FakeDistributedCache(sp.GetRequiredService())); + + services.AddIdentityServer(options => + { + options.UserInteraction.LoginUrl = "/account/login"; + options.UserInteraction.LogoutUrl = "/account/logout"; + options.UserInteraction.ConsentUrl = "/consent"; + options.KeyManagement.Enabled = false; + }) + .AddSigningCredential(selfSignedCertificate) + .AddSaml() + .AddInMemorySamlServiceProviders(_serviceProviders); + }, + app => + { + app.UseIdentityServer(); + + app.MapGet("/account/login", () => Results.Ok()); + app.MapGet("/account/logout", () => Results.Ok()); + app.MapGet("/consent", () => Results.Ok()); + + app.MapGet("/__signin", async (HttpContext ctx) => + { + if (_userToSignIn?.Identity == null) + { + throw new InvalidOperationException( + $"Must set user prior to signin and must have an identity"); + } + + await ctx.SignInAsync(_userToSignIn, new AuthenticationProperties()); + _userToSignIn = null; + ctx.Response.StatusCode = 204; + }); + + app.MapGet("/__signout", async ctx => + { + await ctx.SignOutAsync(); + ctx.Response.StatusCode = 204; + }); + }, + _ct); + } + + private async Task InitializeServiceProvider(string identityProviderHostUri, X509Certificate2? signingCertificate = null) => SpHost = await KestrelTestHost.Create(output, + services => + { + services.AddAuthentication(opt => + { + opt.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + opt.DefaultChallengeScheme = Saml2Defaults.Scheme; + }) + .AddCookie() + .AddSaml2(opt => + { + opt.SPOptions.EntityId = new Sustainsys.Saml2.Metadata.EntityId("https://localhost:5001/Saml2"); + opt.SPOptions.WantAssertionsSigned = false; + if (signingCertificate != null) + { + opt.SPOptions.ServiceCertificates.Add(signingCertificate); + } + + opt.IdentityProviders.Add( + new IdentityProvider(new Sustainsys.Saml2.Metadata.EntityId(identityProviderHostUri), opt.SPOptions) + { + LoadMetadata = true, + MetadataLocation = $"{identityProviderHostUri}/saml/metadata", + SingleSignOnServiceUrl = new Uri($"{identityProviderHostUri}/saml/signin"), + WantAuthnRequestsSigned = signingCertificate != null + }); + }); + services.AddAuthorization(); + }, + app => + { + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapGet("/protected-resource", () => "Protected Resource").RequireAuthorization(); + + app.MapGet("/user-name-identifier", async context => + { + var userId = context.User.FindFirst(ClaimTypes.NameIdentifier); + if (userId == null || string.IsNullOrWhiteSpace(userId.Value)) + { + throw new InvalidOperationException("No name identifier claim found for user or claim had no value."); + } + + await context.Response.WriteAsync(userId.Value, context.RequestAborted); + }).RequireAuthorization(); + }, + _ct); + + public async ValueTask DisposeAsync() + { + if (SpHost != null) + { + await SpHost.DisposeAsync(); + } + + if (IdpHost != null) + { + await IdpHost.DisposeAsync(); + } + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSigninTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSigninTests.cs new file mode 100644 index 000000000..0e6d2764b --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/SustainSysSigninTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; +using System.Web; +using static Duende.IdentityServer.IntegrationTests.Endpoints.Saml.SamlTestHelpers; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +public class SustainSysSigninTests(ITestOutputHelper output) +{ + private const string Category = "SustainSys SAML signin"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private SustainSysSamlTestFixture Fixture = new(output); + + [Fact] + [Trait("Category", Category)] + public async Task can_initiate_saml_signin_request() + { + // Arrange + await Fixture.InitializeAsync(); + + await Fixture.LoginUserAtIdentityProvider(); + + // Act + var result = await Fixture.BrowserClient!.GetAsync("/protected-resource"); + + // Assert + var acsResult = await ManuallySubmitSamlFormResponse(result); + + // completing the flow should result in receiving a response from the initial protected resource request + acsResult.StatusCode.ShouldBe(HttpStatusCode.OK); + var acsResponse = await acsResult.Content.ReadAsStringAsync(); + acsResponse.ShouldBe("Protected Resource"); + } + + [Fact] + [Trait("Category", Category)] + public async Task can_initiate_signed_saml_signin_request() + { + // Arrange + Fixture.GenerateSigningCertificate(); + await Fixture.InitializeAsync(); + + await Fixture.LoginUserAtIdentityProvider(); + + // Act + var result = await Fixture.BrowserClient!.GetAsync("/protected-resource"); + + // Assert + var acsResult = await ManuallySubmitSamlFormResponse(result); + + // completing the flow should result in receiving a response from the initial protected resource request + acsResult.StatusCode.ShouldBe(HttpStatusCode.OK); + var acsResponse = await acsResult.Content.ReadAsStringAsync(); + acsResponse.ShouldBe("Protected Resource"); + } + + [Fact] + [Trait("Category", Category)] + public async Task can_initiate_signin_request_for_encrypted_assertions() + { + // Arrange + Fixture.GenerateSigningCertificate(); + Fixture.RequireEncryptedAssertions(); + await Fixture.InitializeAsync(); + + await Fixture.LoginUserAtIdentityProvider(); + + // Act + var result = await Fixture.BrowserClient!.GetAsync("/protected-resource"); + + // Assert + var acsResult = await ManuallySubmitSamlFormResponse(result); + + // completing the flow should result in receiving a response from the initial protected resource request + acsResult.StatusCode.ShouldBe(HttpStatusCode.OK); + var acsResponse = await acsResult.Content.ReadAsStringAsync(); + acsResponse.ShouldBe("Protected Resource"); + + // verify subject id was also parsed correctly after decrypting assertions + var userInfo = await Fixture.BrowserClient!.GetAsync("/user-name-identifier"); + var userInfoResponse = await userInfo.Content.ReadAsStringAsync(); + userInfoResponse.ShouldBe("user-id"); + } + + private async Task ManuallySubmitSamlFormResponse(HttpResponseMessage response) + { + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + // since HttpClient doesn't support JavaScript, we need to extra the content from the auto form post and manually + // complete the callback to the Service Provider's ACS URL the same way a user in a browser with JavaScript disabled + // would have to manually submit the form + var (samlResponse, relayState, acsUrl) = await ExtractSamlResponse(response, _ct); + var formData = new Dictionary { { "SAMLResponse", ConvertToBase64Encoded(samlResponse) } }; + if (!string.IsNullOrEmpty(relayState)) + { + formData.Add("RelayState", HttpUtility.UrlEncode(relayState)); + } + using var formContent = new FormUrlEncodedContent(formData); + var acsResult = await Fixture.BrowserClient!.PostAsync(acsUrl, formContent, _ct); + + return acsResult; + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/TestSamlClaimsMapper.cs b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/TestSamlClaimsMapper.cs new file mode 100644 index 000000000..38359e79c --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/Endpoints/Saml/TestSamlClaimsMapper.cs @@ -0,0 +1,28 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Saml; +using Duende.IdentityServer.Saml.Models; + +namespace Duende.IdentityServer.IntegrationTests.Endpoints.Saml; + +/// +/// Test implementation of ISamlClaimsMapper that returns a single custom attribute. +/// Used by both unit and integration tests. +/// +public class TestSamlClaimsMapper : ISamlClaimsMapper +{ + public Task> MapClaimsAsync(SamlClaimsMappingContext mappingContext) + { + var attributes = new List + { + new() + { + Name = "CUSTOM_MAPPED", + NameFormat = "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + Values = new List { "custom_value" } + } + }; + return Task.FromResult>(attributes); + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj b/identity-server/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj index a4c1d02c6..e0f2988b7 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj +++ b/identity-server/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj @@ -15,8 +15,11 @@ + + + diff --git a/identity-server/test/IdentityServer.IntegrationTests/TestFramework/KestrelTestHost.cs b/identity-server/test/IdentityServer.IntegrationTests/TestFramework/KestrelTestHost.cs new file mode 100644 index 000000000..00cfa0c67 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/TestFramework/KestrelTestHost.cs @@ -0,0 +1,78 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#pragma warning disable CS8602 + +using System.Net; +using Duende.IdentityServer.IntegrationTests.Endpoints.Saml; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.IntegrationTests.TestFramework; + +internal class KestrelTestHost : IAsyncDisposable +{ + private readonly WebApplication _app; + private readonly int _portNumber; + + private KestrelTestHost(WebApplication app, int portNumber) + { + _app = app; + _portNumber = portNumber; + } + + public string Uri() => $"https://localhost:{_portNumber}"; + + public static async Task Create( + ITestOutputHelper output, + Action configureServices, + Action configureApp, + CancellationToken ct) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions()); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("https://127.0.0.1:0"); + builder.Logging.AddXUnit(output); + configureServices(builder.Services); + var app = builder.Build(); + configureApp(app); + await app.StartAsync(ct); + + var uri = app.GetBaseUri(); + return new KestrelTestHost(app, uri.Port); + } + + public HttpClient CreateClient(bool allowAutoRedirect = true) => new( + new CookieHandler(new HttpClientHandler { AllowAutoRedirect = allowAutoRedirect }, new CookieContainer())) + { + BaseAddress = new Uri($"https://localhost:{_portNumber}") + }; + + public IServiceProvider ConfiguredServices => _app.Services; + + public async ValueTask DisposeAsync() => await _app.DisposeAsync(); +} + +public static class WebApplicationExtensions +{ + extension(WebApplication app) + { + public Uri GetBaseUri() + { + var server = app.Services.GetRequiredService(); + var serverAddress = server.Features.Get(); + var url = serverAddress.Addresses.First(); + return new Uri(url); + } + + public HttpClient CreateClient(bool allowAutoRedirect = true) => new( + new CookieHandler(new HttpClientHandler { AllowAutoRedirect = allowAutoRedirect }, new CookieContainer())) + { + BaseAddress = app.GetBaseUri() + }; + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Common/MockSamlLogoutNotificationService.cs b/identity-server/test/IdentityServer.UnitTests/Common/MockSamlLogoutNotificationService.cs new file mode 100644 index 000000000..b92d9c52a --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Common/MockSamlLogoutNotificationService.cs @@ -0,0 +1,19 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; + +namespace UnitTests.Common; + +public class MockSamlLogoutNotificationService : ISamlLogoutNotificationService +{ + public bool GetSamlFrontChannelLogoutsAsyncCalled { get; set; } + public List SamlFrontChannelLogouts { get; set; } = []; + + public Task> GetSamlFrontChannelLogoutsAsync(LogoutNotificationContext context, Ct _) + { + GetSamlFrontChannelLogoutsAsyncCalled = true; + return Task.FromResult(SamlFrontChannelLogouts.AsEnumerable()); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Common/MockSamlSigningService.cs b/identity-server/test/IdentityServer.UnitTests/Common/MockSamlSigningService.cs new file mode 100644 index 000000000..2cac83a16 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Common/MockSamlSigningService.cs @@ -0,0 +1,25 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography.X509Certificates; +using Duende.IdentityServer.Internal.Saml.Infrastructure; + +namespace UnitTests.Common; + +/// +/// Mock implementation of for testing. +/// +internal class MockSamlSigningService : ISamlSigningService +{ + private readonly X509Certificate2 _certificate; + + public MockSamlSigningService(X509Certificate2 certificate) => _certificate = certificate; + + public Task GetSigningCertificateAsync(Ct _) => Task.FromResult(_certificate); + + public Task GetSigningCertificateBase64Async(Ct _) + { + var certBytes = _certificate.Export(X509ContentType.Cert); + return Task.FromResult(Convert.ToBase64String(certBytes)); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Common/MockUserSession.cs b/identity-server/test/IdentityServer.UnitTests/Common/MockUserSession.cs index c66c01bcd..f0d4d26ae 100644 --- a/identity-server/test/IdentityServer.UnitTests/Common/MockUserSession.cs +++ b/identity-server/test/IdentityServer.UnitTests/Common/MockUserSession.cs @@ -3,6 +3,7 @@ using System.Security.Claims; +using Duende.IdentityServer.Saml.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authentication; @@ -11,6 +12,7 @@ namespace UnitTests.Common; public class MockUserSession : IUserSession { public List Clients = new List(); + public List SamlSessions = new List(); public bool EnsureSessionIdCookieWasCalled { get; set; } public bool RemoveSessionIdCookieWasCalled { get; set; } @@ -52,4 +54,19 @@ public class MockUserSession : IUserSession Clients.Add(clientId); return Task.CompletedTask; } + + public Task AddSamlSessionAsync(SamlSpSessionData session, Ct _) + { + SamlSessions.RemoveAll(s => s.EntityId == session.EntityId); + SamlSessions.Add(session); + return Task.CompletedTask; + } + + public Task> GetSamlSessionListAsync(Ct _) => Task.FromResult>(SamlSessions); + + public Task RemoveSamlSessionAsync(string entityId, Ct _) + { + SamlSessions.RemoveAll(s => s.EntityId == entityId); + return Task.CompletedTask; + } } diff --git a/identity-server/test/IdentityServer.UnitTests/Common/TestSamlClaimsMapper.cs b/identity-server/test/IdentityServer.UnitTests/Common/TestSamlClaimsMapper.cs new file mode 100644 index 000000000..1162f198f --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Common/TestSamlClaimsMapper.cs @@ -0,0 +1,28 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Saml; +using Duende.IdentityServer.Saml.Models; + +namespace UnitTests.Common; + +/// +/// Test implementation of ISamlClaimsMapper that returns a single custom attribute. +/// Used by both unit and integration tests. +/// +public class TestSamlClaimsMapper : ISamlClaimsMapper +{ + public Task> MapClaimsAsync(SamlClaimsMappingContext mappingContext) + { + var attributes = new List + { + new() + { + Name = "CUSTOM_MAPPED", + NameFormat = "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + Values = new List { "custom_value" } + } + }; + return Task.FromResult>(attributes); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Endpoints/EndSession/EndSessionCallbackResultTests.cs b/identity-server/test/IdentityServer.UnitTests/Endpoints/EndSession/EndSessionCallbackResultTests.cs index 45d31502c..0345b9e02 100644 --- a/identity-server/test/IdentityServer.UnitTests/Endpoints/EndSession/EndSessionCallbackResultTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Endpoints/EndSession/EndSessionCallbackResultTests.cs @@ -2,10 +2,16 @@ // See LICENSE in the project root for license information. +using System.Net; +using System.Text.RegularExpressions; +using Duende.IdentityModel; +using Duende.IdentityServer; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Endpoints.Results; +using Duende.IdentityServer.Internal.Saml.SingleLogout; using Duende.IdentityServer.Validation; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Testing; namespace UnitTests.Endpoints.EndSession; @@ -24,20 +30,59 @@ public class EndSessionCallbackResultTests IsError = false, }; _options = new IdentityServerOptions(); - _subject = new EndSessionCallbackHttpWriter(_options); + _subject = new EndSessionCallbackHttpWriter(_options, new FakeLogger()); } [Fact] public async Task default_options_should_emit_frame_src_csp_headers() { _validationResult.FrontChannelLogoutUrls = new[] { "http://foo" }; + _validationResult.SamlFrontChannelLogouts = [new SamlHttpRedirectFrontChannelLogout(new Uri("http://bar"), string.Empty)]; var ctx = new DefaultHttpContext(); ctx.Request.Method = "GET"; await _subject.WriteHttpResponse(new EndSessionCallbackResult(_validationResult), ctx); - ctx.Response.Headers.ContentSecurityPolicy.First().ShouldContain("frame-src http://foo"); + ctx.Response.Headers.ContentSecurityPolicy.First().ShouldContain("frame-src http://foo http://bar"); + } + + [Fact] + public async Task default_options_should_emit_script_src_hash_for_saml_iframe_auto_post() + { + _validationResult.FrontChannelLogoutUrls = new[] { "http://foo" }; + _validationResult.SamlFrontChannelLogouts = [new SamlHttpPostFrontChannelLogout(new Uri("http://bar"), string.Empty, null)]; + + var ctx = new DefaultHttpContext(); + ctx.Request.Method = "GET"; + + await _subject.WriteHttpResponse(new EndSessionCallbackResult(_validationResult), ctx); + + ctx.Response.Headers.ContentSecurityPolicy.First().ShouldContain($"script-src '{IdentityServerConstants.ContentSecurityPolicyHashes.SamlAutoPostScript}'"); + } + + [Fact] + public async Task csp_hash_should_match_inline_script() + { + _validationResult.SamlFrontChannelLogouts = [new SamlHttpPostFrontChannelLogout(new Uri("http://foo"), string.Empty, null)]; + + var ctx = new DefaultHttpContext(); + ctx.Request.Method = "GET"; + ctx.Response.Body = new MemoryStream(); + + await _subject.WriteHttpResponse(new EndSessionCallbackResult(_validationResult), ctx); + + ctx.Response.StatusCode.ShouldBe(200); + ctx.Response.ContentType.ShouldStartWith("text/html"); + ctx.Response.Body.Seek(0, SeekOrigin.Begin); + using var rdr = new StreamReader(ctx.Response.Body); + var html = await rdr.ReadToEndAsync(); + + var match = Regex.Match(html, "<script>(.*?)</script>", RegexOptions.Singleline | RegexOptions.IgnoreCase); + match.Success.ShouldBeTrue(); + + var scriptSha256 = "sha256-" + WebUtility.HtmlDecode(match.Groups[1].Value).ToSha256(); + scriptSha256.ShouldBe(IdentityServerConstants.ContentSecurityPolicyHashes.SamlAutoPostScript); } [Fact] @@ -45,6 +90,7 @@ public class EndSessionCallbackResultTests { _options.Authentication.RequireCspFrameSrcForSignout = false; _validationResult.FrontChannelLogoutUrls = new[] { "http://foo" }; + _validationResult.SamlFrontChannelLogouts = [new SamlHttpRedirectFrontChannelLogout(new Uri("http://bar"), string.Empty)]; var ctx = new DefaultHttpContext(); ctx.Request.Method = "GET"; diff --git a/identity-server/test/IdentityServer.UnitTests/Endpoints/Results/EndSessionCallbackResultTests.cs b/identity-server/test/IdentityServer.UnitTests/Endpoints/Results/EndSessionCallbackResultTests.cs index 20dbad1e5..c8dee230f 100644 --- a/identity-server/test/IdentityServer.UnitTests/Endpoints/Results/EndSessionCallbackResultTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Endpoints/Results/EndSessionCallbackResultTests.cs @@ -8,8 +8,10 @@ using Duende.IdentityServer; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Endpoints.Results; using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; using Duende.IdentityServer.Validation; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Testing; using UnitTests.Common; namespace UnitTests.Endpoints.Results; @@ -29,7 +31,7 @@ public class EndSessionCallbackResultTests _context.Request.Host = new HostString("server"); _context.Response.Body = new MemoryStream(); - _subject = new EndSessionCallbackHttpWriter(_options); + _subject = new EndSessionCallbackHttpWriter(_options, new FakeLogger()); } [Fact] @@ -113,4 +115,144 @@ public class EndSessionCallbackResultTests _context.Response.Headers.ContentSecurityPolicy.First().ShouldContain($"style-src '{IdentityServerConstants.ContentSecurityPolicyHashes.EndSessionStyle}'"); _context.Response.Headers["X-Content-Security-Policy"].ShouldBeEmpty(); } + + [Fact] + public async Task saml_http_redirect_logout_should_render_iframe() + { + _result.IsError = false; + _result.SamlFrontChannelLogouts = + [ + new MockSamlFrontChannelLogout + { + SamlBinding = SamlBinding.HttpRedirect, + Destination = new Uri("https://sp.example.com/slo"), + EncodedContent = "SAMLRequest=abc123&SigAlg=xyz&Signature=sig", + RelayState = null + } + ]; + + await _subject.WriteHttpResponse(new EndSessionCallbackResult(_result), _context); + + _context.Response.Body.Seek(0, SeekOrigin.Begin); + using var rdr = new StreamReader(_context.Response.Body); + var html = await rdr.ReadToEndAsync(); + + html.ShouldContain(""); + } + + [Fact] + public async Task saml_http_post_logout_should_render_iframe_with_srcdoc() + { + _result.IsError = false; + _result.SamlFrontChannelLogouts = + [ + new MockSamlFrontChannelLogout + { + SamlBinding = SamlBinding.HttpPost, + Destination = new Uri("https://sp.example.com/slo"), + EncodedContent = "base64encodedlogoutrequest", + RelayState = "state123" + } + ]; + + await _subject.WriteHttpResponse(new EndSessionCallbackResult(_result), _context); + + _context.Response.Body.Seek(0, SeekOrigin.Begin); + using var rdr = new StreamReader(_context.Response.Body); + var html = await rdr.ReadToEndAsync(); + + html.ShouldContain(""); + html.ShouldContain(""); + } + + [Fact] + public async Task multiple_saml_logouts_should_render_multiple_iframes() + { + _result.IsError = false; + _result.SamlFrontChannelLogouts = + [ + new MockSamlFrontChannelLogout + { + SamlBinding = SamlBinding.HttpRedirect, + Destination = new Uri("https://sp1.example.com/slo"), + EncodedContent = "SAMLRequest=sp1", + RelayState = null + }, + new MockSamlFrontChannelLogout + { + SamlBinding = SamlBinding.HttpPost, + Destination = new Uri("https://sp2.example.com/slo"), + EncodedContent = "base64sp2", + RelayState = null + } + ]; + + await _subject.WriteHttpResponse(new EndSessionCallbackResult(_result), _context); + + _context.Response.Body.Seek(0, SeekOrigin.Begin); + using var rdr = new StreamReader(_context.Response.Body); + var html = await rdr.ReadToEndAsync(); + + html.ShouldContain("https://sp1.example.com/slo"); + html.ShouldContain("https://sp2.example.com/slo"); + } + + [Fact] + public async Task saml_logout_with_unknown_binding_should_be_skipped() + { + _result.IsError = false; + _result.SamlFrontChannelLogouts = + [ + new MockSamlFrontChannelLogout + { + SamlBinding = (SamlBinding)999, // Unknown binding + Destination = new Uri("https://sp.example.com/slo"), + EncodedContent = "content", + RelayState = null + } + ]; + + await _subject.WriteHttpResponse(new EndSessionCallbackResult(_result), _context); + + _context.Response.Body.Seek(0, SeekOrigin.Begin); + using var rdr = new StreamReader(_context.Response.Body); + var html = await rdr.ReadToEndAsync(); + + html.ShouldNotContain("https://sp.example.com/slo"); + } + + private class MockSamlFrontChannelLogout : ISamlFrontChannelLogout + { + public required SamlBinding SamlBinding { get; init; } + public required Uri Destination { get; init; } + public required string EncodedContent { get; init; } + public required string RelayState { get; init; } + } } diff --git a/identity-server/test/IdentityServer.UnitTests/Extensions/AuthenticationPropertiesExtensionsTests.cs b/identity-server/test/IdentityServer.UnitTests/Extensions/AuthenticationPropertiesExtensionsTests.cs new file mode 100644 index 000000000..9c4db4b92 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Extensions/AuthenticationPropertiesExtensionsTests.cs @@ -0,0 +1,214 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Saml.Models; +using Microsoft.AspNetCore.Authentication; + +namespace UnitTests.Extensions; + +public class AuthenticationPropertiesExtensionsTests +{ + private const string Category = "AuthenticationPropertiesExtensions"; + + [Fact] + [Trait("Category", Category)] + public void get_saml_session_list_when_no_sessions_should_return_empty() + { + var properties = new AuthenticationProperties(); + + var sessions = properties.GetSamlSessionList(); + + sessions.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public void add_saml_session_when_new_session_should_add_to_list() + { + var properties = new AuthenticationProperties(); + var session = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + properties.AddSamlSession(session); + + var sessions = properties.GetSamlSessionList().ToList(); + sessions.ShouldHaveSingleItem(); + sessions[0].EntityId.ShouldBe("https://sp1.example.com"); + sessions[0].SessionIndex.ShouldBe("abc123"); + sessions[0].NameId.ShouldBe("user@example.com"); + } + + [Fact] + [Trait("Category", Category)] + public void add_saml_session_when_multiple_sessions_should_add_all_to_list() + { + var properties = new AuthenticationProperties(); + var session1 = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + var session2 = new SamlSpSessionData + { + EntityId = "https://sp2.example.com", + SessionIndex = "def456", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + properties.AddSamlSession(session1); + properties.AddSamlSession(session2); + + var sessions = properties.GetSamlSessionList().ToList(); + sessions.Count.ShouldBe(2); + sessions.ShouldContain(s => s.EntityId == "https://sp1.example.com"); + sessions.ShouldContain(s => s.EntityId == "https://sp2.example.com"); + } + + [Fact] + [Trait("Category", Category)] + public void add_saml_session_when_duplicate_entity_id_should_update_session() + { + var properties = new AuthenticationProperties(); + var session1 = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + var session2 = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", // Same EntityId + SessionIndex = "abc123", // Same SessionIndex (reused) + NameId = "updated@example.com", // Updated NameId + NameIdFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + }; + + properties.AddSamlSession(session1); + properties.AddSamlSession(session2); + + var sessions = properties.GetSamlSessionList().ToList(); + sessions.ShouldHaveSingleItem(); + sessions[0].EntityId.ShouldBe("https://sp1.example.com"); + sessions[0].SessionIndex.ShouldBe("abc123"); + sessions[0].NameId.ShouldBe("updated@example.com"); + sessions[0].NameIdFormat.ShouldBe("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"); + } + + [Fact] + [Trait("Category", Category)] + public void remove_saml_session_when_session_exists_should_remove_it() + { + var properties = new AuthenticationProperties(); + var session1 = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + var session2 = new SamlSpSessionData + { + EntityId = "https://sp2.example.com", + SessionIndex = "def456", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + properties.AddSamlSession(session1); + properties.AddSamlSession(session2); + properties.RemoveSamlSession("https://sp1.example.com"); + + var sessions = properties.GetSamlSessionList().ToList(); + sessions.ShouldHaveSingleItem(); + sessions[0].EntityId.ShouldBe("https://sp2.example.com"); + } + + [Fact] + [Trait("Category", Category)] + public void remove_saml_session_when_session_does_not_exist_should_do_nothing() + { + var properties = new AuthenticationProperties(); + var session = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + properties.AddSamlSession(session); + properties.RemoveSamlSession("https://sp2.example.com"); + + var sessions = properties.GetSamlSessionList().ToList(); + sessions.ShouldHaveSingleItem(); + sessions[0].EntityId.ShouldBe("https://sp1.example.com"); + } + + [Fact] + [Trait("Category", Category)] + public void saml_session_data_serialization_roundtrip_should_preserve_data() + { + var properties = new AuthenticationProperties(); + var originalSession = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123def456", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + properties.AddSamlSession(originalSession); + + // Retrieve the session + var retrievedSessions = properties.GetSamlSessionList().ToList(); + + retrievedSessions.ShouldHaveSingleItem(); + var retrievedSession = retrievedSessions[0]; + retrievedSession.EntityId.ShouldBe(originalSession.EntityId); + retrievedSession.SessionIndex.ShouldBe(originalSession.SessionIndex); + retrievedSession.NameId.ShouldBe(originalSession.NameId); + retrievedSession.NameIdFormat.ShouldBe(originalSession.NameIdFormat); + } + + [Fact] + [Trait("Category", Category)] + public void set_saml_session_list_when_empty_list_should_remove_key() + { + var properties = new AuthenticationProperties(); + var session = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "abc123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + properties.AddSamlSession(session); + properties.SetSamlSessionList(Array.Empty()); + + properties.Items.ContainsKey("saml_session_list").ShouldBeFalse(); + properties.GetSamlSessionList().ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public void session_index_generation_should_be_unique() + { + var sessionIndex1 = Guid.NewGuid().ToString("N"); + var sessionIndex2 = Guid.NewGuid().ToString("N"); + + sessionIndex1.ShouldNotBe(sessionIndex2); + sessionIndex1.Length.ShouldBe(32); // GUID without hyphens + sessionIndex2.Length.ShouldBe(32); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Extensions/EndpointOptionsExtensionsTests.cs b/identity-server/test/IdentityServer.UnitTests/Extensions/EndpointOptionsExtensionsTests.cs index 73a59f8e7..3217b451a 100644 --- a/identity-server/test/IdentityServer.UnitTests/Extensions/EndpointOptionsExtensionsTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Extensions/EndpointOptionsExtensionsTests.cs @@ -132,5 +132,71 @@ public class EndpointOptionsExtensionsTests actual.ShouldBe(expectedIsEndpointEnabled); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsEndpointEnabledShouldReturnExpectedForSamlMetadataEndpoint(bool expectedIsEndpointEnabled) + { + _options.EnableSamlMetadataEndpoint = expectedIsEndpointEnabled; + var actual = _options.IsEndpointEnabled(CreateTestEndpoint(IdentityServerConstants.EndpointNames.SamlMetadata)); + + actual.ShouldBe(expectedIsEndpointEnabled); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsEndpointEnabledShouldReturnExpectedForSamlSigninEndpoint(bool expectedIsEndpointEnabled) + { + _options.EnableSamlSigninEndpoint = expectedIsEndpointEnabled; + var actual = _options.IsEndpointEnabled(CreateTestEndpoint(IdentityServerConstants.EndpointNames.SamlSignin)); + + actual.ShouldBe(expectedIsEndpointEnabled); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsEndpointEnabledShouldReturnExpectedForSamlSigninCallbackEndpoint(bool expectedIsEndpointEnabled) + { + _options.EnableSamlSigninCallbackEndpoint = expectedIsEndpointEnabled; + var actual = _options.IsEndpointEnabled(CreateTestEndpoint(IdentityServerConstants.EndpointNames.SamlSigninCallback)); + + actual.ShouldBe(expectedIsEndpointEnabled); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsEndpointEnabledShouldReturnExpectedForSamlIdpInitiatedEndpoint(bool expectedIsEndpointEnabled) + { + _options.EnableSamlIdpInitiatedEndpoint = expectedIsEndpointEnabled; + var actual = _options.IsEndpointEnabled(CreateTestEndpoint(IdentityServerConstants.EndpointNames.SamlIdpInitiated)); + + actual.ShouldBe(expectedIsEndpointEnabled); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsEndpointEnabledShouldReturnExpectedForSamlLogoutEndpoint(bool expectedIsEndpointEnabled) + { + _options.EnableSamlLogoutEndpoint = expectedIsEndpointEnabled; + var actual = _options.IsEndpointEnabled(CreateTestEndpoint(IdentityServerConstants.EndpointNames.SamlLogout)); + + actual.ShouldBe(expectedIsEndpointEnabled); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsEndpointEnabledShouldReturnExpectedForSamlLogoutCallbackEndpoint(bool expectedIsEndpointEnabled) + { + _options.EnableSamlLogoutCallbackEndpoint = expectedIsEndpointEnabled; + var actual = _options.IsEndpointEnabled(CreateTestEndpoint(IdentityServerConstants.EndpointNames.SamlLogoutCallback)); + + actual.ShouldBe(expectedIsEndpointEnabled); + } + private Endpoint CreateTestEndpoint(string name) => new Endpoint(name, "", null); } diff --git a/identity-server/test/IdentityServer.UnitTests/Extensions/HttpContextExtensionsTests.cs b/identity-server/test/IdentityServer.UnitTests/Extensions/HttpContextExtensionsTests.cs index 4b2c4a1fb..65f1a8401 100644 --- a/identity-server/test/IdentityServer.UnitTests/Extensions/HttpContextExtensionsTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Extensions/HttpContextExtensionsTests.cs @@ -6,6 +6,7 @@ using System.Security.Claims; using Duende.IdentityModel; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; using Duende.IdentityServer.Services; using Duende.IdentityServer.Stores; using Microsoft.AspNetCore.Http; @@ -204,11 +205,211 @@ public class HttpContextExtensionsTests result.ShouldBeNull(); } - private DefaultHttpContext CreateContextWithUserSession(string? subjectId, params Client[] clients) + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_without_logout_message_returns_null_if_no_saml_service_providers_have_front_channel_logout() + { + var sp = CreateSamlServiceProvider("https://sp.example.com"); + sp.SingleLogoutServiceUrl = null; + var context = CreateContextWithUserSessionAndSaml("Test", [], [sp]); + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(); + + result.ShouldBeNull(); + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_without_logout_message_returns_url_if_saml_service_provider_has_front_channel_logout() + { + var sp = CreateSamlServiceProvider("https://sp.example.com"); + var context = CreateContextWithUserSessionAndSaml("Test", [], [sp]); + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(); + + result.ShouldNotBeNull(); + result.ShouldContain("/connect/endsession/callback?endSessionId="); + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_without_logout_message_returns_url_if_both_oidc_and_saml_have_front_channel_logout() + { + var client = new Client + { + ClientId = "oidc_client", + AllowedGrantTypes = GrantTypes.ClientCredentials, + RequireClientSecret = false, + AllowedScopes = { "api1" }, + FrontChannelLogoutUri = "http://oidc-client/logout" + }; + var sp = CreateSamlServiceProvider("https://sp.example.com"); + var context = CreateContextWithUserSessionAndSaml("Test", [client], [sp]); + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(); + + result.ShouldNotBeNull(); + result.ShouldContain("/connect/endsession/callback?endSessionId="); + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_with_logout_message_includes_saml_service_providers() + { + var sp = CreateSamlServiceProvider("https://sp.example.com"); + var context = CreateContextWithUserSessionAndSaml("Test", [], [sp]); + var logoutMessage = new LogoutMessage + { + SubjectId = "Test", + SessionId = "session-id", + ClientIds = [], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + } + ] + }; + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(logoutMessage); + + result.ShouldNotBeNull(); + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_with_logout_message_returns_null_if_saml_service_provider_disabled() + { + var sp = CreateSamlServiceProvider("https://sp.example.com"); + sp.Enabled = false; + var context = CreateContextWithUserSessionAndSaml("Test", [], [sp]); + var logoutMessage = new LogoutMessage + { + SubjectId = "Test", + SessionId = "session-id", + ClientIds = [], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + } + ] + }; + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(logoutMessage); + + result.ShouldBeNull(); + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_with_logout_message_merges_current_user_saml_sessions() + { + var sp1 = CreateSamlServiceProvider("https://sp1.example.com"); + var sp2 = CreateSamlServiceProvider("https://sp2.example.com"); + var context = CreateContextWithUserSessionAndSaml("Test", [], [sp1, sp2]); + + // Logout message only has sp1, but current user has session with sp2 + var logoutMessage = new LogoutMessage + { + SubjectId = "Test", + SessionId = "session-id", + ClientIds = [], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + } + ] + }; + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(logoutMessage); + + result.ShouldNotBeNull(); + // Both sp1 and sp2 should be included since current user matches logout message subject + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsync_with_logout_message_combines_didc_and_saml() + { + var client = new Client + { + ClientId = "oidc_client", + AllowedGrantTypes = GrantTypes.ClientCredentials, + RequireClientSecret = false, + AllowedScopes = { "api1" }, + FrontChannelLogoutUri = "http://oidc-client/logout" + }; + var sp = CreateSamlServiceProvider("https://sp.example.com"); + var context = CreateContextWithUserSessionAndSaml("Test", [client], [sp]); + var logoutMessage = new LogoutMessage + { + SubjectId = "Test", + SessionId = "session-id", + ClientIds = ["oidc_client"], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + } + ] + }; + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(logoutMessage); + + result.ShouldNotBeNull(); + } + + [Fact] + public async Task GetIdentityServerSignoutFrameCallbackUrlAsyncWithEmptyLogoutMessageReturnsNull() + { + var context = CreateContextWithUserSessionAndSaml("Test", [], []); + var logoutMessage = new LogoutMessage + { + SubjectId = "Test", + SessionId = "session-id", + ClientIds = [], + SamlSessions = [] + }; + + var result = await context.GetIdentityServerSignoutFrameCallbackUrlAsync(logoutMessage); + + result.ShouldBeNull(); + } + + private static SamlServiceProvider CreateSamlServiceProvider(string entityId) => new SamlServiceProvider + { + EntityId = entityId, + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri($"{entityId}/acs")], + SingleLogoutServiceUrl = new SamlEndpointType + { + Binding = SamlBinding.HttpRedirect, + Location = new Uri($"{entityId}/slo") + }, + Enabled = true + }; + + private DefaultHttpContext CreateContextWithUserSessionAndSaml(string? subjectId, Client[] clients, SamlServiceProvider[] serviceProviders) { var userSession = new MockUserSession { Clients = clients.Select(client => client.ClientId).ToList(), + SamlSessions = serviceProviders + .Where(sp => sp.Enabled) + .Select(sp => new SamlSpSessionData + { + EntityId = sp.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = $"session-{sp.EntityId}" + }).ToList() }; if (subjectId != null) @@ -223,6 +424,35 @@ public class HttpContextExtensionsTests services.AddSingleton(new FakeTimeProvider()); services.AddSingleton, MockMessageStore>(); services.AddSingleton(new MockServerUrls()); + services.AddSingleton(new InMemorySamlServiceProviderStore(serviceProviders.ToArray())); + + return new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider() + }; + } + + private DefaultHttpContext CreateContextWithUserSession(string? subjectId, params Client[] clients) + { + var userSession = new MockUserSession + { + Clients = clients.Select(client => client.ClientId).ToList(), + }; + + if (subjectId != null) + { + userSession.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.Subject, subjectId)])); + } + + var clientStore = new InMemoryClientStore(clients); + var serviceProviderStore = new InMemorySamlServiceProviderStore([]); + var services = new ServiceCollection(); + services.AddSingleton(userSession); + services.AddSingleton(clientStore); + services.AddSingleton(new FakeTimeProvider()); + services.AddSingleton(serviceProviderStore); + services.AddSingleton, MockMessageStore>(); + services.AddSingleton(new MockServerUrls()); return new DefaultHttpContext { diff --git a/identity-server/test/IdentityServer.UnitTests/Models/SamlSpSessionDataTests.cs b/identity-server/test/IdentityServer.UnitTests/Models/SamlSpSessionDataTests.cs new file mode 100644 index 000000000..862fe3ca8 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Models/SamlSpSessionDataTests.cs @@ -0,0 +1,177 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Saml.Models; + +namespace UnitTests.Models; + +public class SamlSpSessionDataTests +{ + private const string Category = "SamlSpSessionData"; + + [Fact] + [Trait("Category", Category)] + public void equals_should_return_true_for_same_entity_id_and_session_index() + { + var session1 = new SamlSpSessionData + { + EntityId = "https://sp.example.com", + SessionIndex = "session123", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + }; + + var session2 = new SamlSpSessionData + { + EntityId = "https://sp.example.com", + SessionIndex = "session123", + NameId = "different@example.com", // Different NameId shouldn't matter + NameIdFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" // Different format shouldn't matter + }; + + session1.Equals(session2).ShouldBeTrue(); + session1.GetHashCode().ShouldBe(session2.GetHashCode()); + } + + [Fact] + [Trait("Category", Category)] + public void equals_should_return_false_for_different_entity_id() + { + var session1 = new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "session123", + NameId = "user@example.com" + }; + + var session2 = new SamlSpSessionData + { + EntityId = "https://sp2.example.com", + SessionIndex = "session123", + NameId = "user@example.com" + }; + + session1.Equals(session2).ShouldBeFalse(); + } + + [Fact] + [Trait("Category", Category)] + public void equals_should_return_false_for_different_session_index() + { + var session1 = new SamlSpSessionData + { + EntityId = "https://sp.example.com", + SessionIndex = "session123", + NameId = "user@example.com" + }; + + var session2 = new SamlSpSessionData + { + EntityId = "https://sp.example.com", + SessionIndex = "session456", + NameId = "user@example.com" + }; + + session1.Equals(session2).ShouldBeFalse(); + } + + [Fact] + [Trait("Category", Category)] + public void union_should_deduplicate_sessions() + { + var list1 = new List + { + new() + { + EntityId = "https://sp1.example.com", + SessionIndex = "session1", + NameId = "user@example.com" + }, + new() + { + EntityId = "https://sp2.example.com", + SessionIndex = "session2", + NameId = "user@example.com" + } + }; + + var list2 = new List + { + new() + { + EntityId = "https://sp1.example.com", + SessionIndex = "session1", + NameId = "different@example.com" // Duplicate session (different NameId shouldn't matter) + }, + new() + { + EntityId = "https://sp3.example.com", + SessionIndex = "session3", + NameId = "user@example.com" + } + }; + + var result = list1.Union(list2).ToList(); + + // Should have 3 unique sessions (sp1/session1, sp2/session2, sp3/session3) + result.Count.ShouldBe(3); + result.Count(s => s.EntityId == "https://sp1.example.com").ShouldBe(1); + result.Count(s => s.EntityId == "https://sp2.example.com").ShouldBe(1); + result.Count(s => s.EntityId == "https://sp3.example.com").ShouldBe(1); + } + + [Fact] + [Trait("Category", Category)] + public void distinct_should_remove_duplicate_sessions() + { + var sessions = new List + { + new() + { + EntityId = "https://sp.example.com", + SessionIndex = "session1", + NameId = "user1@example.com" + }, + new() + { + EntityId = "https://sp.example.com", + SessionIndex = "session1", + NameId = "user2@example.com" // Duplicate (different NameId) + }, + new() + { + EntityId = "https://sp.example.com", + SessionIndex = "session2", + NameId = "user@example.com" + } + }; + + var result = sessions.Distinct().ToList(); + + result.Count.ShouldBe(2); + } + + [Fact] + [Trait("Category", Category)] + public void contains_should_work_correctly() + { + var sessions = new List + { + new() + { + EntityId = "https://sp.example.com", + SessionIndex = "session1", + NameId = "user@example.com" + } + }; + + var lookupSession = new SamlSpSessionData + { + EntityId = "https://sp.example.com", + SessionIndex = "session1", + NameId = "different@example.com" // Different NameId shouldn't matter + }; + + sessions.Contains(lookupSession).ShouldBeTrue(); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/AuthNRequestParserTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/AuthNRequestParserTests.cs new file mode 100644 index 000000000..b338e7f22 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/AuthNRequestParserTests.cs @@ -0,0 +1,155 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.SingleSignin; +using Microsoft.Extensions.Logging.Abstractions; + +namespace UnitTests.Saml; + +/// +/// Unit tests for AuthNRequestParser, focusing on NameIDPolicy parsing +/// +public class AuthNRequestParserTests +{ + private const string Category = "SAML AuthN Request Parser"; + + private readonly AuthNRequestParser _parser = new(NullLogger.Instance); + + private static XDocument CreateAuthNRequest(string? nameIdPolicyXml = null) + { + var xml = $@" + + https://sp.example.com + {nameIdPolicyXml ?? ""} +"; + + return XDocument.Parse(xml); + } + + [Fact] + [Trait("Category", Category)] + public void parse_with_format_only_should_succeed() + { + // Arrange + var nameIdPolicyXml = @""; + var doc = CreateAuthNRequest(nameIdPolicyXml); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldNotBeNull(); + result.NameIdPolicy.Format.ShouldBe("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"); + result.NameIdPolicy.SPNameQualifier.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void parse_with_sp_name_qualifier_should_succeed() + { + // Arrange + var nameIdPolicyXml = @""; + var doc = CreateAuthNRequest(nameIdPolicyXml); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldNotBeNull(); + result.NameIdPolicy.SPNameQualifier.ShouldBe("https://custom.sp.com"); + result.NameIdPolicy.Format.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void parse_with_all_attributes_should_succeed() + { + // Arrange + var nameIdPolicyXml = @""; + var doc = CreateAuthNRequest(nameIdPolicyXml); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldNotBeNull(); + result.NameIdPolicy.Format.ShouldBe("urn:oasis:names:tc:SAML:2.0:nameid-format:transient"); + result.NameIdPolicy.SPNameQualifier.ShouldBe("https://sp.example.com"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_without_name_id_policy_should_return_null() + { + // Arrange + var doc = CreateAuthNRequest(null); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void parse_with_empty_name_id_policy_element_should_return_non_null() + { + // Arrange + var nameIdPolicyXml = @""; + var doc = CreateAuthNRequest(nameIdPolicyXml); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldNotBeNull(); + result.NameIdPolicy.Format.ShouldBeNull(); + result.NameIdPolicy.SPNameQualifier.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void parse_with_whitespace_in_format_should_trim() + { + // Arrange + var nameIdPolicyXml = @""; + var doc = CreateAuthNRequest(nameIdPolicyXml); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldNotBeNull(); + result.NameIdPolicy.Format.ShouldBe("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_with_whitespace_in_sp_name_qualifier_should_trim() + { + // Arrange + var nameIdPolicyXml = @""; + var doc = CreateAuthNRequest(nameIdPolicyXml); + + // Act + var result = _parser.Parse(doc); + + // Assert + result.NameIdPolicy.ShouldNotBeNull(); + result.NameIdPolicy.SPNameQualifier.ShouldBe("https://sp.example.com"); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/LimitedReadStreamTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/LimitedReadStreamTests.cs new file mode 100644 index 000000000..e7887a88c --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/LimitedReadStreamTests.cs @@ -0,0 +1,361 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Text; +using Duende.IdentityServer.Internal.Saml.Infrastructure; + +namespace UnitTests.Saml; + +public class LimitedReadStreamTests +{ + private const string Category = "SAML Limited Read Stream"; + + [Fact] + [Trait("Category", Category)] + public void read_within_limit_should_succeed() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello World"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + var buffer = new byte[data.Length]; + + // Act + var bytesRead = limitedStream.Read(buffer, 0, buffer.Length); + + // Assert + bytesRead.ShouldBe(data.Length); + Encoding.UTF8.GetString(buffer).ShouldBe("Hello World"); + } + + [Fact] + [Trait("Category", Category)] + public void read_exactly_at_limit_should_succeed() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, data.Length); + var buffer = new byte[data.Length]; + + // Act + var bytesRead = limitedStream.Read(buffer, 0, buffer.Length); + + // Assert + bytesRead.ShouldBe(data.Length); + Encoding.UTF8.GetString(buffer).ShouldBe("Hello"); + } + + [Fact] + [Trait("Category", Category)] + public void read_exceeds_limit_should_throw_invalid_operation_exception() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello World"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 5); + var buffer = new byte[data.Length]; + + // Act - first read should succeed + limitedStream.ReadExactly(buffer, 0, 5); + + // Assert - second read should throw + var exception = Should.Throw(() => + limitedStream.Read(buffer, 0, buffer.Length)); + + exception.Message.ShouldBe("Maximum stream size exceeded."); + } + + [Fact] + [Trait("Category", Category)] + public void read_multiple_reads_within_limit_should_succeed() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello World"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + var buffer1 = new byte[5]; + var buffer2 = new byte[6]; + + // Act + var bytesRead1 = limitedStream.Read(buffer1, 0, buffer1.Length); + var bytesRead2 = limitedStream.Read(buffer2, 0, buffer2.Length); + + // Assert + bytesRead1.ShouldBe(5); + bytesRead2.ShouldBe(6); + Encoding.UTF8.GetString(buffer1).ShouldBe("Hello"); + Encoding.UTF8.GetString(buffer2).ShouldBe(" World"); + } + + [Fact] + [Trait("Category", Category)] + public void read_multiple_reads_exceeding_limit_should_throw_on_excess() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello World"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 7); + var buffer = new byte[5]; + + // Act & Assert + limitedStream.Read(buffer, 0, 5).ShouldBe(5); // 5 bytes read + limitedStream.Read(buffer, 0, 2).ShouldBe(2); // 7 bytes total (at limit) + + Should.Throw(() => + limitedStream.Read(buffer, 0, 1)); // Would exceed limit + } + + [Fact] + [Trait("Category", Category)] + public void read_empty_stream_should_return_zero() + { + // Arrange + using var innerStream = new MemoryStream(); + using var limitedStream = new LimitedReadStream(innerStream, 100); + var buffer = new byte[10]; + + // Act + var bytesRead = limitedStream.Read(buffer, 0, buffer.Length); + + // Assert + bytesRead.ShouldBe(0); + } + + [Fact] + [Trait("Category", Category)] + public void read_with_zero_max_bytes_should_throw_immediately() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 0); + var buffer = new byte[5]; + + // Act & Assert + Should.Throw(() => + limitedStream.Read(buffer, 0, buffer.Length)); + } + + [Fact] + [Trait("Category", Category)] + public void can_read_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Assert + limitedStream.CanRead.ShouldBe(innerStream.CanRead); + limitedStream.CanRead.ShouldBeTrue(); + } + + [Fact] + [Trait("Category", Category)] + public void can_seek_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Assert + limitedStream.CanSeek.ShouldBe(innerStream.CanSeek); + limitedStream.CanSeek.ShouldBeTrue(); + } + + [Fact] + [Trait("Category", Category)] + public void can_write_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Assert + limitedStream.CanWrite.ShouldBe(innerStream.CanWrite); + limitedStream.CanWrite.ShouldBeTrue(); + } + + [Fact] + [Trait("Category", Category)] + public void length_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Assert + limitedStream.Length.ShouldBe(innerStream.Length); + limitedStream.Length.ShouldBe(data.Length); + } + + [Fact] + [Trait("Category", Category)] + public void position_get_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act + var buffer = new byte[3]; + limitedStream.ReadExactly(buffer, 0, 3); + + // Assert + limitedStream.Position.ShouldBe(innerStream.Position); + limitedStream.Position.ShouldBe(3); + } + + [Fact] + [Trait("Category", Category)] + public void position_set_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act + limitedStream.Position = 2; + + // Assert + limitedStream.Position.ShouldBe(2); + innerStream.Position.ShouldBe(2); + } + + [Fact] + [Trait("Category", Category)] + public void seek_should_delegate_to_inner_stream() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello World"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act + var newPosition = limitedStream.Seek(6, SeekOrigin.Begin); + + // Assert + newPosition.ShouldBe(6); + limitedStream.Position.ShouldBe(6); + innerStream.Position.ShouldBe(6); + } + + [Fact] + [Trait("Category", Category)] + public void flush_should_delegate_to_inner_stream() + { + // Arrange + using var innerStream = new MemoryStream(); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act & Assert - should not throw + Should.NotThrow(() => limitedStream.Flush()); + } + + [Fact] + [Trait("Category", Category)] + public void write_should_delegate_to_inner_stream() + { + // Arrange + using var innerStream = new MemoryStream(); + using var limitedStream = new LimitedReadStream(innerStream, 100); + var data = Encoding.UTF8.GetBytes("Hello"); + + // Act + limitedStream.Write(data, 0, data.Length); + + // Assert + innerStream.ToArray().ShouldBe(data); + } + + [Fact] + [Trait("Category", Category)] + public void set_length_should_delegate_to_inner_stream() + { + // Arrange + using var innerStream = new MemoryStream(); + using var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act + limitedStream.SetLength(50); + + // Assert + limitedStream.Length.ShouldBe(50); + innerStream.Length.ShouldBe(50); + } + + [Fact] + [Trait("Category", Category)] + public void dispose_should_dispose_inner_stream() + { + // Arrange + var innerStream = new MemoryStream(); + var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act + limitedStream.Dispose(); + + // Assert + Should.Throw(() => innerStream.ReadByte()); + } + + [Fact] + [Trait("Category", Category)] + public async Task dispose_async_should_dispose_inner_stream() + { + // Arrange + var innerStream = new MemoryStream(); + var limitedStream = new LimitedReadStream(innerStream, 100); + + // Act + await limitedStream.DisposeAsync(); + + // Assert + Should.Throw(() => innerStream.ReadByte()); + } + + [Fact] + [Trait("Category", Category)] + public void read_limits_read_size_when_requested_count_exceeds_limit() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello World"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 5); + var buffer = new byte[100]; + + // Act - request 100 bytes but limit is 5 + var bytesRead = limitedStream.Read(buffer, 0, 100); + + // Assert + bytesRead.ShouldBe(5); + Encoding.UTF8.GetString(buffer, 0, bytesRead).ShouldBe("Hello"); + } + + [Fact] + [Trait("Category", Category)] + public void read_with_offset_should_write_to_correct_position() + { + // Arrange + var data = Encoding.UTF8.GetBytes("Hello"); + using var innerStream = new MemoryStream(data); + using var limitedStream = new LimitedReadStream(innerStream, 100); + var buffer = new byte[10]; + + // Act + var bytesRead = limitedStream.Read(buffer, 3, 5); + + // Assert + bytesRead.ShouldBe(5); + buffer[0].ShouldBe((byte)0); + buffer[1].ShouldBe((byte)0); + buffer[2].ShouldBe((byte)0); + Encoding.UTF8.GetString(buffer, 3, bytesRead).ShouldBe("Hello"); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/LogoutRequestParserTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/LogoutRequestParserTests.cs new file mode 100644 index 000000000..b6913a9c0 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/LogoutRequestParserTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.SingleLogout; +using Microsoft.Extensions.Logging.Abstractions; + +namespace UnitTests.Saml; + +public class LogoutRequestParserTests +{ + private const string Category = "Logout Request Parser"; + + private readonly LogoutRequestParser _parser = new(NullLogger.Instance); + + private static string CreateLogoutRequest( + string? id = null, + string? issuer = null, + string? destination = null, + string? nameId = null, + string? sessionIndex = null) + { + id ??= "_test-logout-id"; + issuer ??= "https://sp.example.com"; + destination ??= "https://idp.example.com/saml/logout"; + nameId ??= "user@example.com"; + sessionIndex ??= "_session123"; + + return $@" + + {issuer} + {nameId} + {sessionIndex} +"; + } + + [Fact] + [Trait("Category", Category)] + public void parse_valid_logout_request_returns_success() + { + var xmlString = CreateLogoutRequest(); + var doc = XDocument.Parse(xmlString); + + var result = _parser.Parse(doc); + + result.ShouldNotBeNull(); + result.Id.ShouldBe("_test-logout-id"); + result.Issuer.ShouldBe("https://sp.example.com"); + result.Destination!.ToString().ShouldBe("https://idp.example.com/saml/logout"); + result.NameId.Value.ShouldBe("user@example.com"); + result.SessionIndex.ShouldBe("_session123"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_missing_id_throws_exception() + { + var xml = @" + + https://sp.example.com +"; + var doc = XDocument.Parse(xml); + + Should.Throw(() => _parser.Parse(doc)) + .Message.ShouldContain("ID"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_missing_issuer_throws_exception() + { + var xml = @" + +"; + var doc = XDocument.Parse(xml); + + Should.Throw(() => _parser.Parse(doc)) + .Message.ShouldContain("Issuer"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_invalid_xml_throws_exception() + { + var xml = ""; + var doc = XDocument.Parse(xml); + + Should.Throw(() => _parser.Parse(doc)); + } + + [Fact] + [Trait("Category", Category)] + public void parse_missing_destination_still_succeeds() + { + var xml = @" + + https://sp.example.com + user@example.com + _session123 +"; + var doc = XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.ShouldNotBeNull(); + result.Destination.ShouldBeNull(); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/RequestedAuthnContextParsingTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/RequestedAuthnContextParsingTests.cs new file mode 100644 index 000000000..a4b88c9d5 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/RequestedAuthnContextParsingTests.cs @@ -0,0 +1,217 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Internal.Saml.SingleSignin; +using Duende.IdentityServer.Saml.Models; +using Microsoft.Extensions.Logging.Abstractions; + +namespace UnitTests.Saml; + +public class RequestedAuthnContextParsingTests +{ + private const string Category = "Requested AuthN Context Parsing"; + + private readonly AuthNRequestParser _parser = new(NullLogger.Instance); + + private const string BaseAuthNRequest = """ + + https://sp.example.com + {0} + + """; + + [Fact] + [Trait("Category", Category)] + public void parse_single_authn_context_class_ref_succeeds() + { + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldNotBeNull(); + result.RequestedAuthnContext.AuthnContextClassRefs.Count.ShouldBe(1); + result.RequestedAuthnContext.AuthnContextClassRefs.First().ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:Password"); + result.RequestedAuthnContext.Comparison.ShouldBe(AuthnContextComparison.Exact); + } + + [Fact] + [Trait("Category", Category)] + public void parse_multiple_authn_context_class_ref_succeeds() + { + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + urn:oasis:names:tc:SAML:2.0:ac:classes:X509 + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldNotBeNull(); + result.RequestedAuthnContext.AuthnContextClassRefs.Count.ShouldBe(3); + result.RequestedAuthnContext.AuthnContextClassRefs.ShouldContain("urn:oasis:names:tc:SAML:2.0:ac:classes:Password"); + result.RequestedAuthnContext.AuthnContextClassRefs.ShouldContain("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"); + result.RequestedAuthnContext.AuthnContextClassRefs.ShouldContain("urn:oasis:names:tc:SAML:2.0:ac:classes:X509"); + result.RequestedAuthnContext.Comparison.ShouldBe(AuthnContextComparison.Minimum); + } + + [Theory] + [InlineData("exact", AuthnContextComparison.Exact)] + [InlineData("minimum", AuthnContextComparison.Minimum)] + [InlineData("maximum", AuthnContextComparison.Maximum)] + [InlineData("better", AuthnContextComparison.Better)] + [InlineData("EXACT", AuthnContextComparison.Exact)] + [InlineData("MINIMUM", AuthnContextComparison.Minimum)] + [InlineData("Exact", AuthnContextComparison.Exact)] + [Trait("Category", Category)] + public void parse_comparison_attribute_succeeds(string comparisonValue, AuthnContextComparison expected) + { + var requestedAuthnContext = $""" + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldNotBeNull(); + result.RequestedAuthnContext.Comparison.ShouldBe(expected); + } + + [Fact] + [Trait("Category", Category)] + public void parse_omitted_comparison_defaults_to_exact() + { + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldNotBeNull(); + result.RequestedAuthnContext.Comparison.ShouldBe(AuthnContextComparison.Exact); + } + + [Fact] + [Trait("Category", Category)] + public void parse_invalid_comparison_throws() + { + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = Should.Throw(() => _parser.Parse(doc)); + + result.Message.ShouldBe("Unknown AuthnContextComparison: invalid"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_no_authn_context_class_ref_throws() + { + var requestedAuthnContext = """ + + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = Should.Throw(() => _parser.Parse(doc)); + + result.Message.ShouldBe("No AuthnContextClassRef element found in requestedAuthnContext"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_missing_requested_authn_context_returns_null() + { + var xml = string.Format(BaseAuthNRequest, ""); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void parse_empty_authn_context_class_ref_skipped() + { + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + urn:oasis:names:tc:SAML:2.0:ac:classes:X509 + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldNotBeNull(); + result.RequestedAuthnContext.AuthnContextClassRefs.Count.ShouldBe(2); + result.RequestedAuthnContext.AuthnContextClassRefs.ShouldContain("urn:oasis:names:tc:SAML:2.0:ac:classes:Password"); + result.RequestedAuthnContext.AuthnContextClassRefs.ShouldContain("urn:oasis:names:tc:SAML:2.0:ac:classes:X509"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_whitespace_authn_context_class_ref_trimmed() + { + var requestedAuthnContext = """ + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = _parser.Parse(doc); + + result.RequestedAuthnContext.ShouldNotBeNull(); + result.RequestedAuthnContext.AuthnContextClassRefs.First().ShouldBe("urn:oasis:names:tc:SAML:2.0:ac:classes:Password"); + } + + [Fact] + [Trait("Category", Category)] + public void parse_only_empty_authn_context_class_ref_throws() + { + var requestedAuthnContext = """ + + + + + """; + var xml = string.Format(BaseAuthNRequest, requestedAuthnContext); + var doc = System.Xml.Linq.XDocument.Parse(xml); + + var result = Should.Throw(() => _parser.Parse(doc)); + + result.Message.ShouldBe("No AuthnContextClassRef element found in requestedAuthnContext"); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlAssertionEncryptorTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlAssertionEncryptorTests.cs new file mode 100644 index 000000000..40a07ac1c --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlAssertionEncryptorTests.cs @@ -0,0 +1,316 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; + +namespace UnitTests.Saml; + +public class SamlAssertionEncryptorTests +{ + private const string Category = "SAML Assertion Encryptor"; + + private static readonly XNamespace SamlNs = "urn:oasis:names:tc:SAML:2.0:assertion"; + private static readonly XNamespace SamlpNs = "urn:oasis:names:tc:SAML:2.0:protocol"; + private static readonly XNamespace EncNs = "http://www.w3.org/2001/04/xmlenc#"; + + private readonly SamlAssertionEncryptor _encryptor = new(new FakeTimeProvider(DateTimeOffset.UtcNow), NullLogger.Instance); + + private static X509Certificate2 CreateTestEncryptionCertificate(DateTimeOffset? notBefore = null, DateTimeOffset? notAfter = null, int? keySize = 2048) + { + using var rsa = RSA.Create(keySize!.Value); + var request = new CertificateRequest( + "CN=Test SP Encryption", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DataEncipherment, + critical: true)); + + var cert = request.CreateSelfSigned( + notBefore ?? DateTimeOffset.UtcNow.AddDays(-1), + notAfter ?? DateTimeOffset.UtcNow.AddDays(365)); + + var exported = cert.Export(X509ContentType.Pfx, "test"); + return X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable); + } + + private static XElement CreateTestResponse() => new(SamlpNs + "Response", + new XAttribute("ID", "_" + Guid.NewGuid().ToString("N")), + new XAttribute("Version", "2.0"), + new XAttribute("IssueInstant", DateTime.UtcNow.ToString("o")), + new XElement(SamlNs + "Issuer", "https://idp.example.com"), + new XElement(SamlpNs + "Status", + new XElement(SamlpNs + "StatusCode", + new XAttribute("Value", "urn:oasis:names:tc:SAML:2.0:status:Success"))), + new XElement(SamlNs + "Assertion", + new XAttribute("ID", "_" + Guid.NewGuid().ToString("N")), + new XAttribute("Version", "2.0"), + new XAttribute("IssueInstant", DateTime.UtcNow.ToString("o")), + new XElement(SamlNs + "Issuer", "https://idp.example.com"), + new XElement(SamlNs + "Subject", + new XElement(SamlNs + "NameID", "user@example.com")), + new XElement(SamlNs + "AttributeStatement", + new XElement(SamlNs + "Attribute", + new XAttribute("Name", "email"), + new XElement(SamlNs + "AttributeValue", "user@example.com"))))); + + [Fact] + [Trait("Category", Category)] + public void no_encryption_certificates_configured_for_service_provider_should_throw() + { + // Arrange + var response = CreateTestResponse(); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")] + // No EncryptionCertificates configured + }; + + var originalXml = response.ToString(SaveOptions.DisableFormatting); + + // Act & Assert + var result = Should.Throw(() => _encryptor.EncryptAssertion(originalXml, sp)); + + result.Message.ShouldBe($"No valid encryption certificate found for {sp.EntityId}. Certificates may be expired, not yet valid, or lacking required RSA keys."); + } + + [Fact] + [Trait("Category", Category)] + public void valid_certificate_should_encrypt_assertion() + { + // Arrange + var response = CreateTestResponse(); + var cert = CreateTestEncryptionCertificate(); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [cert] + }; + + var originalXml = response.ToString(SaveOptions.DisableFormatting); + + // Act + var resultXml = _encryptor.EncryptAssertion(originalXml, sp); + + // Assert + resultXml.ShouldNotBeNull(); + + var result = XElement.Parse(resultXml); + + // Verify plain assertion removed + var plainAssertion = result.Element(SamlNs + "Assertion"); + plainAssertion.ShouldBeNull("Plain assertion should be removed after encryption"); + + // Verify encrypted assertion added + var encryptedAssertion = result.Element(SamlNs + "EncryptedAssertion"); + encryptedAssertion.ShouldNotBeNull("Encrypted assertion should be present"); + + // Verify structure (EncryptedKey is inside KeyInfo) + var encryptedData = encryptedAssertion.Element(EncNs + "EncryptedData"); + encryptedData.ShouldNotBeNull(); + + var dsNs = XNamespace.Get("http://www.w3.org/2000/09/xmldsig#"); + var keyInfo = encryptedData.Element(dsNs + "KeyInfo"); + keyInfo.ShouldNotBeNull(); + + var encryptedKey = keyInfo.Element(EncNs + "EncryptedKey"); + encryptedKey.ShouldNotBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void expired_certificate_should_throw_exception() + { + // Arrange + var response = CreateTestResponse(); + var expiredCert = CreateTestEncryptionCertificate(DateTimeOffset.UtcNow.AddDays(-365), DateTimeOffset.UtcNow.AddDays(-1)); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [expiredCert] + }; + + var responseXml = response.ToString(SaveOptions.DisableFormatting); + + // Act & Assert + var exception = Should.Throw(() => _encryptor.EncryptAssertion(responseXml, sp)); + + exception.Message.ShouldBe($"No valid encryption certificate found for {sp.EntityId}. Certificates may be expired, not yet valid, or lacking required RSA keys."); + } + + [Fact] + [Trait("Category", Category)] + public void not_yet_valid_certificate_should_throw_exception() + { + // Arrange + var response = CreateTestResponse(); + var notYetValidCert = CreateTestEncryptionCertificate(DateTimeOffset.UtcNow.AddDays(1), DateTimeOffset.UtcNow.AddDays(5)); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [notYetValidCert] + }; + + var responseXml = response.ToString(SaveOptions.DisableFormatting); + + // Act & Assert + var exception = Should.Throw(() => _encryptor.EncryptAssertion(responseXml, sp)); + + exception.Message.ShouldBe($"No valid encryption certificate found for {sp.EntityId}. Certificates may be expired, not yet valid, or lacking required RSA keys."); + } + + [Fact] + [Trait("Category", Category)] + public void no_rsa_public_key_should_throw_exception() + { + // Arrange + var response = CreateTestResponse(); + + // Create certificate without RSA key (EC key instead) + using var ecdsa = ECDsa.Create(); + var request = new CertificateRequest(new X500DistinguishedName("CN=Test EC Certificate"), ecdsa, HashAlgorithmName.SHA256); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [cert] + }; + + var responseXml = response.ToString(SaveOptions.DisableFormatting); + + // Act & Assert + var exception = Should.Throw(() => + _encryptor.EncryptAssertion(responseXml, sp)); + + exception.Message.ShouldBe($"No valid encryption certificate found for {sp.EntityId}. Certificates may be expired, not yet valid, or lacking required RSA keys."); + } + + [Fact] + [Trait("Category", Category)] + public void insufficient_key_size_in_certificate_should_throw_exception() + { + // Arrange + var response = CreateTestResponse(); + + // Create certificate with RSA key with too small of key size + var certWithInsufficientKeySizeInCertificate = CreateTestEncryptionCertificate(keySize: 1024); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [certWithInsufficientKeySizeInCertificate] + }; + + var responseXml = response.ToString(SaveOptions.DisableFormatting); + + // Act & Assert + var exception = Should.Throw(() => + _encryptor.EncryptAssertion(responseXml, sp)); + + exception.Message.ShouldBe($"No valid encryption certificate found for {sp.EntityId}. Certificates may be expired, not yet valid, or lacking required RSA keys."); + } + + [Fact] + [Trait("Category", Category)] + public void no_assertion_should_throw_exception() + { + // Arrange - Response without assertion + var response = new XElement(SamlpNs + "Response", + new XAttribute("ID", "_" + Guid.NewGuid().ToString("N")), + new XAttribute("Version", "2.0"), + new XAttribute("IssueInstant", DateTime.UtcNow.ToString("o")), + new XElement(SamlNs + "Issuer", "https://idp.example.com"), + new XElement(SamlpNs + "Status", + new XElement(SamlpNs + "StatusCode", + new XAttribute("Value", "urn:oasis:names:tc:SAML:2.0:status:Success")))); + // No Assertion element + + var cert = CreateTestEncryptionCertificate(); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [cert] + }; + + var responseXml = response.ToString(SaveOptions.DisableFormatting); + + // Act & Assert + var exception = Should.Throw(() => _encryptor.EncryptAssertion(responseXml, sp)); + + exception.Message.ShouldBe($"SAML Response does not contain an Assertion element for {sp.EntityId}"); + } + + [Fact] + [Trait("Category", Category)] + public void valid_input_should_replace_assertion() + { + // Arrange + var response = CreateTestResponse(); + var cert = CreateTestEncryptionCertificate(); + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + EncryptionCertificates = [cert] + }; + + // Capture original response structure + var originalResponseId = response.Attribute("ID")?.Value; + var originalIssuer = response.Element(SamlNs + "Issuer")?.Value; + var responseXml = response.ToString(SaveOptions.DisableFormatting); + + // Act + var resultXml = _encryptor.EncryptAssertion(responseXml, sp); + + // Assert - Response structure preserved + var result = XElement.Parse(resultXml); + result.Name.ShouldBe(SamlpNs + "Response"); + result.Attribute("ID")?.Value.ShouldBe(originalResponseId); + result.Element(SamlNs + "Issuer")?.Value.ShouldBe(originalIssuer); + + var status = result.Element(SamlpNs + "Status"); + status.ShouldNotBeNull(); + status.Element(SamlpNs + "StatusCode")?.Attribute("Value")?.Value + .ShouldBe("urn:oasis:names:tc:SAML:2.0:status:Success"); + + // Assert - Only assertion changed + var children = result.Elements().ToList(); + children.Count.ShouldBe(3); // Issuer, Status, EncryptedAssertion (was Assertion) + + // Issuer should be first + children[0].Name.ShouldBe(SamlNs + "Issuer"); + + // Status should be second + children[1].Name.ShouldBe(SamlpNs + "Status"); + + // EncryptedAssertion should be third (replaced Assertion) + children[2].Name.ShouldBe(SamlNs + "EncryptedAssertion"); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlClaimsServiceTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlClaimsServiceTests.cs new file mode 100644 index 000000000..3444dfb65 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlClaimsServiceTests.cs @@ -0,0 +1,370 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Collections.ObjectModel; +using System.Security.Claims; +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Internal.Saml; +using Duende.IdentityServer.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using UnitTests.Common; + +namespace UnitTests.Saml; + +public class SamlClaimsServiceTests +{ + private const string Category = "SAML Claims Service"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private readonly SamlOptions _samlOptions; + private readonly IOptions _options; + private readonly MockProfileService _profileService; + private readonly SamlClaimsService _service; + + public SamlClaimsServiceTests() + { + _samlOptions = new SamlOptions(); + _options = Options.Create(_samlOptions); + _profileService = new MockProfileService(); + _service = new SamlClaimsService(_profileService, NullLogger.Instance, _options); + } + + [Fact] + [Trait("Category", Category)] + public async Task default_mappings_should_map_common_oidc_claims() + { + // Arrange + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("name", "John Doe"), + new Claim("email", "test@example.com"), + new Claim("role", "Admin") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await _service.GetMappedAttributesAsync(user, sp, _ct)).ToList(); + + // Assert + attributes.Count.ShouldBe(3); + + var nameAttr = attributes.First(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"); + nameAttr.Values.Count.ShouldBe(1); + nameAttr.Values[0].ShouldBe("John Doe"); + + var emailAttr = attributes.First(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"); + emailAttr.Values.Count.ShouldBe(1); + emailAttr.Values[0].ShouldBe("test@example.com"); + + var roleAttr = attributes.First(a => a.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/role"); + roleAttr.Values.Count.ShouldBe(1); + roleAttr.Values[0].ShouldBe("Admin"); + } + + [Fact] + [Trait("Category", Category)] + public async Task claim_types_constants_should_map_correctly() + { + // Arrange - use custom OID mappings for this test + var customMappings = new Dictionary + { + [ClaimTypes.NameIdentifier] = "urn:oid:0.9.2342.19200300.100.1.1", + [ClaimTypes.Email] = "urn:oid:0.9.2342.19200300.100.1.3", + [ClaimTypes.GivenName] = "urn:oid:2.5.4.42", + [ClaimTypes.Surname] = "urn:oid:2.5.4.4" + }; + var optionsWithOidMappings = new SamlOptions + { + DefaultClaimMappings = new ReadOnlyDictionary(customMappings) + }; + var service = new SamlClaimsService(_profileService, NullLogger.Instance, Options.Create(optionsWithOidMappings)); + + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "user123"), + new Claim(ClaimTypes.Email, "user@example.com"), + new Claim(ClaimTypes.GivenName, "Jane"), + new Claim(ClaimTypes.Surname, "Smith") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await service.GetMappedAttributesAsync(user, sp, _ct)).ToList(); + + // Assert + attributes.Count.ShouldBe(4); + attributes.ShouldAllBe(a => a.Name.StartsWith("urn:oid:")); + } + + [Fact] + [Trait("Category", Category)] + public async Task cleared_default_mappings_should_exclude_unmapped_claims() + { + // Arrange + var optionsWithNoMappings = new SamlOptions + { + DefaultClaimMappings = new ReadOnlyDictionary(new Dictionary()) + }; + var service = new SamlClaimsService(_profileService, NullLogger.Instance, Options.Create(optionsWithNoMappings)); + + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("email", "test@example.com"), + new Claim("custom_claim", "custom_value") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await service.GetMappedAttributesAsync(user, sp, _ct)).ToList(); + + // Assert + attributes.Count.ShouldBe(0); // No mappings, so no attributes + } + + [Fact] + [Trait("Category", Category)] + public async Task custom_global_mappings_should_apply_custom_mappings() + { + // Arrange + var customMappings = new Dictionary + { + ["email"] = "emailAddress", + ["department"] = "ou" + }; + var optionsWithCustomMappings = new SamlOptions + { + DefaultClaimMappings = new ReadOnlyDictionary(customMappings) + }; + var service = new SamlClaimsService(_profileService, NullLogger.Instance, Options.Create(optionsWithCustomMappings)); + + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("email", "test@example.com"), + new Claim("department", "Engineering"), + new Claim("unmapped", "value") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await service.GetMappedAttributesAsync(user, sp, _ct)).ToList(); + + // Assert + attributes.Count.ShouldBe(2); // Only email and department are mapped; sub and unmapped are excluded + attributes.ShouldContain(a => a.Name == "emailAddress"); + attributes.ShouldContain(a => a.Name == "ou"); + attributes.ShouldNotContain(a => a.Name == "sub"); + attributes.ShouldNotContain(a => a.Name == "unmapped"); + } + + [Fact] + [Trait("Category", Category)] + public async Task service_provider_mappings_should_override_global() + { + // Arrange + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("email", "test@example.com"), + new Claim("department", "Engineering") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") }, + ClaimMappings = new ReadOnlyDictionary(new Dictionary + { + ["email"] = "mail", // Override default OID mapping + ["department"] = "businessUnit" + }) + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await _service.GetMappedAttributesAsync(user, sp, _ct)).ToList(); + + // Assert + attributes.Count.ShouldBe(2); // email and department from SP mappings; sub not mapped + attributes.ShouldContain(a => a.Name == "mail" && a.Values[0] == "test@example.com"); + attributes.ShouldContain(a => a.Name == "businessUnit" && a.Values[0] == "Engineering"); + } + + [Fact] + [Trait("Category", Category)] + public async Task service_provider_mappings_should_fall_back_to_global_for_unmapped() + { + // Arrange + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("email", "test@example.com"), + new Claim("given_name", "John") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") }, + ClaimMappings = new ReadOnlyDictionary(new Dictionary + { + ["email"] = "mail" // Override only email + }) + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await _service.GetMappedAttributesAsync(user, sp, _ct)).ToList(); + + // Assert + attributes.Count.ShouldBe(1); // Only email is mapped (overridden by SP); sub and given_name are not in defaults + attributes.ShouldContain(a => a.Name == "mail"); + } + + [Fact] + [Trait("Category", Category)] + public async Task multi_valued_claims_should_group_into_single_attribute() + { + // Arrange + var customMappings = new Dictionary + { + ["sub"] = "urn:oid:0.9.2342.19200300.100.1.1", + ["role"] = "role" // Map role to itself for this test + }; + var optionsWithCustomMappings = new SamlOptions + { + DefaultClaimMappings = new ReadOnlyDictionary(customMappings) + }; + var service = new SamlClaimsService(_profileService, NullLogger.Instance, Options.Create(optionsWithCustomMappings)); + + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("role", "Admin"), + new Claim("role", "User"), + new Claim("role", "Developer") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await service.GetMappedAttributesAsync(user, sp, _ct)).ToList(); + + // Assert + attributes.Count.ShouldBe(2); // sub + role (multi-valued) + + var roleAttr = attributes.First(a => a.Name == "role"); + roleAttr.Values.Count.ShouldBe(3); + roleAttr.Values.ShouldContain("Admin"); + roleAttr.Values.ShouldContain("User"); + roleAttr.Values.ShouldContain("Developer"); + + attributes.ShouldContain(a => a.Name == "urn:oid:0.9.2342.19200300.100.1.1"); // sub mapped to OID + } + + [Fact] + [Trait("Category", Category)] + public async Task custom_mapper_should_use_custom_mapper() + { + // Arrange + var customMapper = new TestSamlClaimsMapper(); + var service = new SamlClaimsService(_profileService, NullLogger.Instance, _options, customMapper); + + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("email", "test@example.com") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await service.GetMappedAttributesAsync(user, sp, _ct)).ToList(); + + // Assert + attributes.Count.ShouldBe(1); + attributes.First().Name.ShouldBe("CUSTOM_MAPPED"); + attributes.First().Values.Count.ShouldBe(1); + attributes.First().Values[0].ShouldBe("custom_value"); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_set_correct_attribute_name_format() + { + // Arrange + var user = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("sub", "test123"), + new Claim("email", "test@example.com") + })); + + var sp = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = new[] { new Uri("https://sp.example.com/acs") } + }; + + _profileService.ProfileClaims = user.Claims.ToList(); + + // Act + var attributes = (await _service.GetMappedAttributesAsync(user, sp, _ct)).ToList(); + + // Assert + attributes.ShouldAllBe(a => a.NameFormat == _samlOptions.DefaultAttributeNameFormat); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlFrontChannelLogoutRequestBuilderTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlFrontChannelLogoutRequestBuilderTests.cs new file mode 100644 index 000000000..d45406711 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlFrontChannelLogoutRequestBuilderTests.cs @@ -0,0 +1,400 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Globalization; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleLogout; +using Duende.IdentityServer.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; + +namespace UnitTests.Saml; + +public class SamlFrontChannelLogoutRequestBuilderTests +{ + private const string Category = "SAML Front Channel Logout Request Builder"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private readonly FakeTimeProvider _timeProvider; + private readonly SamlProtocolMessageSigner _signer; + private readonly SamlFrontChannelLogoutRequestBuilder _subject; + + public SamlFrontChannelLogoutRequestBuilderTests() + { + _timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 2, 5, 12, 0, 0, TimeSpan.Zero)); + _signer = CreateSigner(); + _subject = new SamlFrontChannelLogoutRequestBuilder(_timeProvider, _signer); + } + + [Fact] + [Trait("Category", Category)] + public async Task service_provider_with_no_single_logout_url_should_throw_exception() + { + var sp = CreateServiceProvider(); + sp.SingleLogoutServiceUrl = null; + + await Should.ThrowAsync(async () => + await _subject.BuildLogoutRequestAsync(sp, "user@example.com", null, "session123", "https://idp.example.com", _ct) + ); + } + + [Fact] + [Trait("Category", Category)] + public async Task http_redirect_binding_should_return_redirect_logout() + { + var sp = CreateServiceProvider(); + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "session123", + "https://idp.example.com", + _ct); + + result.SamlBinding.ShouldBe(SamlBinding.HttpRedirect); + result.Destination.ShouldBe(sp.SingleLogoutServiceUrl!.Location); + } + + [Fact] + [Trait("Category", Category)] + public async Task http_post_binding_should_return_post_logout() + { + var sp = CreateServiceProvider(); + sp.SingleLogoutServiceUrl = new SamlEndpointType + { + Binding = SamlBinding.HttpPost, + Location = new Uri("https://sp.example.com/slo") + }; + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "session123", + "https://idp.example.com", + _ct); + + result.SamlBinding.ShouldBe(SamlBinding.HttpPost); + } + + [Fact] + [Trait("Category", Category)] + public async Task unsupported_binding_should_throw_exception() + { + var sp = CreateServiceProvider(); + sp.SingleLogoutServiceUrl = new SamlEndpointType + { + Binding = (SamlBinding)999, // Unsupported binding + Location = new Uri("https://sp.example.com/slo") + }; + + await Should.ThrowAsync(async () => + await _subject.BuildLogoutRequestAsync(sp, "user@example.com", null, "session123", "https://idp.example.com", _ct) + ); + } + + [Fact] + [Trait("Category", Category)] + public async Task http_redirect_should_encode_and_compress_request() + { + var sp = CreateServiceProvider(); + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "session123", + "https://idp.example.com", + _ct); + + result.EncodedContent.ShouldNotBeNullOrEmpty(); + result.EncodedContent.ShouldContain("SAMLRequest="); + result.EncodedContent.ShouldContain("&SigAlg="); + result.EncodedContent.ShouldContain("&Signature="); + } + + [Fact] + [Trait("Category", Category)] + public async Task http_redirect_should_be_decodable_and_decompressible() + { + var sp = CreateServiceProvider(); + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "session123", + "https://idp.example.com", + _ct); + + var queryString = result.EncodedContent; + var samlRequestPart = queryString.Split('&')[0].Replace("?SAMLRequest=", ""); + var decodedBytes = Convert.FromBase64String(Uri.UnescapeDataString(samlRequestPart)); + + using var input = new MemoryStream(decodedBytes); + using var deflateStream = new DeflateStream(input, CompressionMode.Decompress); + using var reader = new StreamReader(deflateStream); + var xml = await reader.ReadToEndAsync(); + + xml.ShouldContain("{issuer}"); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_set_correct_name_id() + { + var sp = CreateServiceProvider(); + var nameId = "user@example.com"; + + var result = await _subject.BuildLogoutRequestAsync( + sp, + nameId, + null, + "session123", + "https://idp.example.com", + _ct); + + var xml = await DecodeRedirectRequest(result.EncodedContent); + xml.ShouldContain($"{nameId}"); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_set_name_id_format_when_provided() + { + var sp = CreateServiceProvider(); + var nameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"; + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + nameIdFormat, + "session123", + "https://idp.example.com", + _ct); + + var xml = await DecodeRedirectRequest(result.EncodedContent); + xml.ShouldContain($"Format=\"{nameIdFormat}\""); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_omit_name_id_format_when_null() + { + var sp = CreateServiceProvider(); + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + null, + "session123", + "https://idp.example.com", + _ct); + + var xml = await DecodeRedirectRequest(result.EncodedContent); + var doc = XDocument.Parse(xml); + var nameIdElement = doc.Descendants().First(e => e.Name.LocalName == "NameID"); + nameIdElement.Attribute("Format").ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_set_correct_session_index() + { + var sp = CreateServiceProvider(); + var sessionIndex = "session123"; + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + null, + sessionIndex, + "https://idp.example.com", + _ct); + + var xml = await DecodeRedirectRequest(result.EncodedContent); + xml.ShouldContain($"{sessionIndex}"); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_generate_unique_request_id() + { + var sp = CreateServiceProvider(); + + var result1 = await _subject.BuildLogoutRequestAsync(sp, "user@example.com", null, "session123", "https://idp.example.com", _ct); + var result2 = await _subject.BuildLogoutRequestAsync(sp, "user@example.com", null, "session123", "https://idp.example.com", _ct); + + var xml1 = await DecodeRedirectRequest(result1.EncodedContent); + var xml2 = await DecodeRedirectRequest(result2.EncodedContent); + + var doc1 = XDocument.Parse(xml1); + var doc2 = XDocument.Parse(xml2); + var id1 = doc1.Root!.Attribute("ID")!.Value; + var id2 = doc2.Root!.Attribute("ID")!.Value; + + id1.ShouldNotBe(id2); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_set_saml_version_2() + { + var sp = CreateServiceProvider(); + + var result = await _subject.BuildLogoutRequestAsync( + sp, + "user@example.com", + null, + "session123", + "https://idp.example.com", + _ct); + + var xml = await DecodeRedirectRequest(result.EncodedContent); + xml.ShouldContain("Version=\"2.0\""); + } + + private static async Task DecodeRedirectRequest(string encodedContent) + { + var samlRequestPart = encodedContent.Split('&')[0].Replace("?SAMLRequest=", ""); + var decodedBytes = Convert.FromBase64String(Uri.UnescapeDataString(samlRequestPart)); + + using var input = new MemoryStream(decodedBytes); + using var deflateStream = new DeflateStream(input, CompressionMode.Decompress); + using var reader = new StreamReader(deflateStream); + return await reader.ReadToEndAsync(); + } + + private static SamlServiceProvider CreateServiceProvider() => new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + SingleLogoutServiceUrl = new SamlEndpointType + { + Binding = SamlBinding.HttpRedirect, + Location = new Uri("https://sp.example.com/slo") + } + }; + + private static SamlProtocolMessageSigner CreateSigner() + { + var cert = CreateTestCertificate(); + var mockSigningService = new UnitTests.Common.MockSamlSigningService(cert); + + return new SamlProtocolMessageSigner( + mockSigningService, + NullLogger.Instance); + } + + private static X509Certificate2 CreateTestCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Test IdP", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + + var exported = cert.Export(X509ContentType.Pfx, "test"); + return X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpPostFrontChannelLogoutTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpPostFrontChannelLogoutTests.cs new file mode 100644 index 000000000..734334986 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpPostFrontChannelLogoutTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Internal.Saml.SingleLogout; +using Duende.IdentityServer.Models; + +namespace UnitTests.Saml; + +public class SamlHttpPostFrontChannelLogoutTests +{ + private const string Category = "SAML HTTP POST Front Channel Logout"; + + [Fact] + [Trait("Category", Category)] + public void constructor_should_set_properties() + { + var destination = new Uri("https://sp.example.com/slo"); + var logoutRequest = "base64encodedrequest"; + var relayState = "state123"; + + var subject = new SamlHttpPostFrontChannelLogout(destination, logoutRequest, relayState); + + subject.Destination.ShouldBe(destination); + subject.EncodedContent.ShouldBe(logoutRequest); + subject.SamlBinding.ShouldBe(SamlBinding.HttpPost); + subject.RelayState.ShouldNotBeNull(); + subject.RelayState.ShouldBe(relayState); + } + + [Fact] + [Trait("Category", Category)] + public void constructor_with_null_relay_state_should_set_relay_state_to_null() + { + var subject = new SamlHttpPostFrontChannelLogout( + new Uri("https://sp.example.com/slo"), + "base64encodedrequest", + null); + + subject.RelayState.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void saml_binding_should_return_http_post() + { + var subject = new SamlHttpPostFrontChannelLogout( + new Uri("https://sp.example.com/slo"), + "base64encodedrequest", + null); + + subject.SamlBinding.ShouldBe(SamlBinding.HttpPost); + } + + [Fact] + [Trait("Category", Category)] + public void relay_state_should_parse_from_string() + { + var relayState = "mystate"; + var subject = new SamlHttpPostFrontChannelLogout( + new Uri("https://sp.example.com/slo"), + "base64encodedrequest", + relayState); + + subject.RelayState.ShouldNotBeNull(); + subject.RelayState.ShouldBe(relayState); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpRedirectFrontChannelLogoutTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpRedirectFrontChannelLogoutTests.cs new file mode 100644 index 000000000..f270f9d9b --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlHttpRedirectFrontChannelLogoutTests.cs @@ -0,0 +1,49 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Internal.Saml.SingleLogout; +using Duende.IdentityServer.Models; + +namespace UnitTests.Saml; + +public class SamlHttpRedirectFrontChannelLogoutTests +{ + private const string Category = "SAML HTTP Redirect Front Channel Logout"; + + [Fact] + [Trait("Category", Category)] + public void constructor_should_set_properties() + { + var destination = new Uri("https://sp.example.com/slo"); + var encodedContent = "?SAMLRequest=abc123&SigAlg=xyz&Signature=sig"; + + var subject = new SamlHttpRedirectFrontChannelLogout(destination, encodedContent); + + subject.Destination.ShouldBe(destination); + subject.EncodedContent.ShouldBe(encodedContent); + subject.SamlBinding.ShouldBe(SamlBinding.HttpRedirect); + subject.RelayState.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public void saml_binding_should_return_http_redirect() + { + var subject = new SamlHttpRedirectFrontChannelLogout( + new Uri("https://sp.example.com/slo"), + "?SAMLRequest=abc"); + + subject.SamlBinding.ShouldBe(SamlBinding.HttpRedirect); + } + + [Fact] + [Trait("Category", Category)] + public void relay_state_should_always_return_null() + { + var subject = new SamlHttpRedirectFrontChannelLogout( + new Uri("https://sp.example.com/slo"), + "?SAMLRequest=abc"); + + subject.RelayState.ShouldBeNull(); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutCallbackProcessorTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutCallbackProcessorTests.cs new file mode 100644 index 000000000..2df8144af --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutCallbackProcessorTests.cs @@ -0,0 +1,273 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using Duende.IdentityServer.Internal.Saml.SingleLogout; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; + +namespace UnitTests.Saml; + +public class SamlLogoutCallbackProcessorTests +{ + private const string Category = "SAML Logout Callback Processor"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private readonly UnitTests.Common.MockMessageStore _logoutMessageStore = new(); + private readonly MockServiceProviderStore _serviceProviderStore = new(); + private readonly LogoutResponseBuilder _logoutResponseBuilder; + private readonly SamlLogoutCallbackProcessor _subject; + + public SamlLogoutCallbackProcessorTests() + { + var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 2, 5, 12, 0, 0, TimeSpan.Zero)); + var issuerNameService = new MockIssuerNameService { IssuerName = "https://idp.example.com" }; + _logoutResponseBuilder = new LogoutResponseBuilder(issuerNameService, timeProvider); + + _subject = new SamlLogoutCallbackProcessor( + _logoutMessageStore, + _serviceProviderStore, + _logoutResponseBuilder, + NullLogger.Instance); + } + + [Fact] + [Trait("Category", Category)] + public async Task invalid_logout_id_should_return_error() + { + var result = await _subject.ProcessAsync("invalid", _ct); + + result.Success.ShouldBeFalse(); + result.Error.Message.ShouldContain("No logout message found"); + } + + [Fact] + [Trait("Category", Category)] + public async Task missing_saml_service_provider_entity_id_should_return_error() + { + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = null + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", _ct); + + result.Success.ShouldBeFalse(); + result.Error.Message.ShouldContain("does not contain SAML SP entity ID"); + } + + [Fact] + [Trait("Category", Category)] + public async Task service_provider_not_found_should_return_error() + { + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = "https://unknown-sp.com", + SamlLogoutRequestId = "_request123" + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", _ct); + + result.Success.ShouldBeFalse(); + result.Error.Message.ShouldContain("Service Provider not found"); + } + + [Fact] + [Trait("Category", Category)] + public async Task disabled_service_provider_should_return_error() + { + var sp = CreateServiceProvider(); + sp.Enabled = false; + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_request123" + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", _ct); + + result.Success.ShouldBeFalse(); + result.Error.Message.ShouldContain("is disabled"); + } + + [Fact] + [Trait("Category", Category)] + public async Task service_provider_with_no_single_logout_url_should_return_error() + { + var sp = CreateServiceProvider(); + sp.SingleLogoutServiceUrl = null; + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_request123" + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", _ct); + + result.Success.ShouldBeFalse(); + result.Error.Message.ShouldContain("has no SingleLogoutServiceUrl"); + } + + [Fact] + [Trait("Category", Category)] + public async Task missing_saml_logout_request_id_should_return_error() + { + var sp = CreateServiceProvider(); + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = null + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", _ct); + + result.Success.ShouldBeFalse(); + result.Error.Message.ShouldContain("does not contain SAML logout request ID"); + } + + [Fact] + [Trait("Category", Category)] + public async Task valid_request_should_return_success() + { + var sp = CreateServiceProvider(); + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_request123", + SamlRelayState = null + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", _ct); + + result.Success.ShouldBeTrue(); + var logoutResponse = result.Value; + logoutResponse.InResponseTo.ShouldBe("_request123"); + logoutResponse.Destination.ShouldBe(sp.SingleLogoutServiceUrl!.Location); + logoutResponse.Status.StatusCode.ShouldBe(SamlStatusCodes.Success); + } + + [Fact] + [Trait("Category", Category)] + public async Task relay_state_should_be_included_in_response() + { + var sp = CreateServiceProvider(); + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_request123", + SamlRelayState = "state456" + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", _ct); + + result.Success.ShouldBeTrue(); + var logoutResponse = result.Value; + logoutResponse.RelayState.ShouldNotBeNull(); + logoutResponse.RelayState.ShouldBe("state456"); + } + + [Fact] + [Trait("Category", Category)] + public async Task without_relay_state_should_have_null_relay_state_in_response() + { + var sp = CreateServiceProvider(); + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_request123", + SamlRelayState = null + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", _ct); + + result.Success.ShouldBeTrue(); + result.Value.RelayState.ShouldBeNull(); + } + + [Fact] + [Trait("Category", Category)] + public async Task should_set_correct_issuer_in_response() + { + var sp = CreateServiceProvider(); + _serviceProviderStore.ServiceProviders[sp.EntityId] = sp; + var logoutMessage = new LogoutMessage + { + SubjectId = "user123", + SessionId = "session123", + SamlServiceProviderEntityId = sp.EntityId, + SamlLogoutRequestId = "_request123" + }; + _logoutMessageStore.Messages["logoutId123"] = new Message(logoutMessage, DateTimeOffset.UtcNow.UtcDateTime); + + var result = await _subject.ProcessAsync("logoutId123", _ct); + + result.Success.ShouldBeTrue(); + result.Value.Issuer.ShouldBe("https://idp.example.com"); + } + + private static SamlServiceProvider CreateServiceProvider() => new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/acs")], + SingleLogoutServiceUrl = new SamlEndpointType + { + Binding = SamlBinding.HttpPost, + Location = new Uri("https://sp.example.com/slo") + }, + Enabled = true + }; + + private class MockServiceProviderStore : ISamlServiceProviderStore + { + public Dictionary ServiceProviders { get; } = []; + + public Task FindByEntityIdAsync(string entityId, Ct _) + { + ServiceProviders.TryGetValue(entityId, out var sp); + return Task.FromResult(sp); + } + } + + private class MockIssuerNameService : IIssuerNameService + { + public string IssuerName { get; set; } = "https://idp.example.com"; + + public Task GetCurrentAsync(Ct _) => Task.FromResult(IssuerName); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutNotificationServiceTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutNotificationServiceTests.cs new file mode 100644 index 000000000..c8a27d524 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlLogoutNotificationServiceTests.cs @@ -0,0 +1,296 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleLogout; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Duende.IdentityServer.Stores; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using UnitTests.Common; +using UnitTests.Validation.Setup; +using SamlBinding = Duende.IdentityServer.Models.SamlBinding; + +namespace UnitTests.Saml; + +public class SamlLogoutNotificationServiceTests +{ + private const string Category = "SAML Logout Notification Service"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private readonly MockUserSession _userSession = new(); + private readonly TestIssuerNameService _issuerNameService = new(); + + private SamlLogoutNotificationService CreateSubject(params SamlServiceProvider[] samlServiceProviders) + { + var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 2, 5, 12, 0, 0, TimeSpan.Zero)); + var signer = CreateSigner(); + var frontChannelLogoutRequestBuilder = new SamlFrontChannelLogoutRequestBuilder(timeProvider, signer); + + return new SamlLogoutNotificationService( + _issuerNameService, + new InMemorySamlServiceProviderStore(samlServiceProviders), + frontChannelLogoutRequestBuilder, + NullLogger.Instance); + } + + private static SamlProtocolMessageSigner CreateSigner() + { + var cert = CreateTestCertificate(); + var mockSigningService = new MockSamlSigningService(cert); + return new SamlProtocolMessageSigner(mockSigningService, NullLogger.Instance); + } + + private static X509Certificate2 CreateTestCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Test IdP", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + + var exported = cert.Export(X509ContentType.Pfx, "test"); + return X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_when_no_service_providers_should_return_empty_list() + { + var context = new LogoutNotificationContext + { + SamlSessions = [] + }; + var subject = CreateSubject(); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context, _ct); + + result.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_when_service_provider_not_found_should_skip_it() + { + var unknownEntityId = "https://unknown-sp.com"; + var context = new LogoutNotificationContext + { + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = unknownEntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + } + ] + }; + var subject = CreateSubject(); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context, _ct); + + result.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_when_service_provider_disabled_should_skip_it() + { + var sp = CreateServiceProvider(); + sp.Enabled = false; + var context = new LogoutNotificationContext + { + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = sp.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + } + ] + }; + var subject = CreateSubject(sp); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context, _ct); + + result.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_when_service_provider_has_no_single_logout_url_should_skip_it() + { + var sp = CreateServiceProvider(); + sp.SingleLogoutServiceUrl = null; + var context = new LogoutNotificationContext + { + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = sp.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + } + ] + }; + var subject = CreateSubject(sp); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context, _ct); + + result.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_when_valid_service_provider_should_build_logout_url() + { + var sp = CreateServiceProvider(); + var context = new LogoutNotificationContext + { + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = sp.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + } + ] + }; + var subject = CreateSubject(sp); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context, _ct); + + result.ShouldHaveSingleItem(); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_when_multiple_valid_service_providers_should_build_multiple_logout_urls() + { + var sp1 = CreateServiceProvider("https://sp1.com"); + var sp2 = CreateServiceProvider("https://sp2.com"); + var context = new LogoutNotificationContext + { + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = sp1.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + }, + new SamlSpSessionData + { + EntityId = sp2.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session456" + } + ] + }; + var subject = CreateSubject(sp1, sp2); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context, _ct); + + result.Count().ShouldBe(2); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_should_use_context_saml_sessions_not_user_session() + { + // Regression test: Ensure we use context.SamlSessions instead of IUserSession + // This prevents a bug where logout fails because the user session is already cleared + var sp = CreateServiceProvider(); + + var context = new LogoutNotificationContext + { + SubjectId = "user123", + SessionId = "session-abc", + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = sp.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + } + ] + }; + + var subject = CreateSubject(sp); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context, _ct); + + result.ShouldHaveSingleItem(); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_saml_front_channel_logouts_async_with_multiple_saml_sessions_should_generate_multiple_logouts() + { + // Regression test: Multiple SPs with different session data + var sp1 = CreateServiceProvider("https://sp1.com"); + var sp2 = CreateServiceProvider("https://sp2.com"); + + var context = new LogoutNotificationContext + { + SubjectId = "user123", + SessionId = "session-abc", + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = sp1.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + }, + new SamlSpSessionData + { + EntityId = sp2.EntityId, + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session456" + } + ] + }; + + var subject = CreateSubject(sp1, sp2); + + var result = await subject.GetSamlFrontChannelLogoutsAsync(context, _ct); + + result.Count().ShouldBe(2); + } + + private static SamlServiceProvider CreateServiceProvider(string entityId = "https://sp.example.com") => new SamlServiceProvider + { + EntityId = entityId, + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri($"{entityId}/acs")], + SingleLogoutServiceUrl = new SamlEndpointType + { + Binding = SamlBinding.HttpRedirect, + Location = new Uri($"{entityId}/slo") + }, + Enabled = true + }; +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SamlProtocolMessageSignerTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SamlProtocolMessageSignerTests.cs new file mode 100644 index 000000000..b07f09a7b --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SamlProtocolMessageSignerTests.cs @@ -0,0 +1,250 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; +using Microsoft.Extensions.Logging.Abstractions; +using UnitTests.Common; + +namespace UnitTests.Saml; + +public class SamlProtocolMessageSignerTests +{ + private const string Category = "SAML Protocol Message Signer"; + + private readonly Ct _ct = TestContext.Current.CancellationToken; + + private readonly SamlServiceProvider _samlServiceProvider = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/saml/acs")] + }; + + private static X509Certificate2 CreateTestCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Test IdP", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + + var exported = cert.Export(X509ContentType.Pfx, "test"); + return X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable); + } + + private SamlProtocolMessageSigner CreateSigner() + { + var cert = CreateTestCertificate(); + var mockSigningService = new MockSamlSigningService(cert); + + return new SamlProtocolMessageSigner( + mockSigningService, + NullLogger.Instance); + } + + private static XElement CreateLogoutResponseElement() + { + var protocolNs = XNamespace.Get(SamlConstants.Namespaces.Protocol); + var assertionNs = XNamespace.Get(SamlConstants.Namespaces.Assertion); + + return new XElement(protocolNs + "LogoutResponse", + new XAttribute("ID", "_test123"), + new XAttribute("Version", "2.0"), + new XAttribute("IssueInstant", "2026-01-29T15:00:00.000Z"), + new XAttribute("Destination", "https://sp.example.com/slo"), + new XAttribute("InResponseTo", "_request123"), + new XElement(assertionNs + "Issuer", "https://idp.example.com"), + new XElement(protocolNs + "Status", + new XElement(protocolNs + "StatusCode", + new XAttribute("Value", SamlStatusCodes.Success)))); + } + + [Fact] + [Trait("Category", Category)] + public async Task sign_protocol_message_should_add_signature() + { + var signer = CreateSigner(); + var logoutResponse = CreateLogoutResponseElement(); + + var signedXml = await signer.SignProtocolMessage(logoutResponse, _samlServiceProvider, _ct); + + signedXml.ShouldContain("Signature"); + signedXml.ShouldContain("SignatureValue"); + signedXml.ShouldContain("X509Certificate"); + } + + [Fact] + [Trait("Category", Category)] + public async Task sign_protocol_message_signature_should_be_placed_after_issuer() + { + var signer = CreateSigner(); + var logoutResponse = CreateLogoutResponseElement(); + + var signedXml = await signer.SignProtocolMessage(logoutResponse, _samlServiceProvider, _ct); + + var indexOfIssuer = signedXml.IndexOf(" + _signingService = new SamlSigningService( + _mockKeyMaterialService, + NullLogger.Instance); + + private static X509Certificate2 CreateTestCertificate(bool includePrivateKey = true) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Test Signing Cert", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + critical: true)); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(1)); + + var exported = cert.Export(X509ContentType.Pfx, "test"); + var certWithKey = X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable); + + if (!includePrivateKey) + { + // Export without private key + var publicOnly = certWithKey.Export(X509ContentType.Cert); + return X509CertificateLoader.LoadCertificate(publicOnly); + } + + return certWithKey; + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_async_with_valid_x509_certificate_should_return_certificate() + { + // Arrange + var cert = CreateTestCertificate(); + var credentials = new SigningCredentials(new X509SecurityKey(cert), SecurityAlgorithms.RsaSha256); + _mockKeyMaterialService.SigningCredentials.Add(credentials); + + // Act + var result = await _signingService.GetSigningCertificateAsync(_ct); + + // Assert + result.ShouldNotBeNull(); + result.HasPrivateKey.ShouldBeTrue(); + result.Subject.ShouldContain("CN=Test Signing Cert"); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_async_with_non_x509_security_key_should_throw_invalid_operation_exception() + { + // Arrange + var key = new SymmetricSecurityKey(new byte[32]); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + _mockKeyMaterialService.SigningCredentials.Add(credentials); + + // Act & Assert + var ex = await Should.ThrowAsync( + async () => await _signingService.GetSigningCertificateAsync(_ct)); + + ex.Message.ShouldBe("Signing credential must be an X509 certificate with private key."); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_async_with_certificate_without_private_key_should_throw_invalid_operation_exception() + { + // Arrange + var cert = CreateTestCertificate(includePrivateKey: false); + var credentials = new SigningCredentials(new X509SecurityKey(cert), SecurityAlgorithms.RsaSha256); + _mockKeyMaterialService.SigningCredentials.Add(credentials); + + // Act & Assert + var ex = await Should.ThrowAsync( + async () => await _signingService.GetSigningCertificateAsync(_ct)); + + ex.Message.ShouldBe("Signing certificate must have a private key."); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_async_with_no_signing_credential_should_throw_invalid_operation_exception() + { + // Arrange - no credentials added to mock service + + // Act & Assert + var ex = await Should.ThrowAsync( + async () => await _signingService.GetSigningCertificateAsync(_ct)); + + ex.Message.ShouldBe("No signing credential available. Configure a signing certificate."); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_base64_async_with_valid_x509_certificate_should_return_base64_string() + { + // Arrange + var cert = CreateTestCertificate(); + var credentials = new SigningCredentials(new X509SecurityKey(cert), SecurityAlgorithms.RsaSha256); + _mockKeyMaterialService.SigningCredentials.Add(credentials); + + // Act + var result = await _signingService.GetSigningCertificateBase64Async(_ct); + + // Assert + result.ShouldNotBeNullOrEmpty(); + // Verify it's valid base64 + var bytes = Convert.FromBase64String(result); + bytes.ShouldNotBeEmpty(); + + // Verify it can be loaded as a certificate + var loadedCert = X509CertificateLoader.LoadCertificate(bytes); + loadedCert.Subject.ShouldContain("CN=Test Signing Cert"); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_base64_async_with_non_x509_security_key_should_throw_invalid_operation_exception() + { + // Arrange + var key = new SymmetricSecurityKey(new byte[32]); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + _mockKeyMaterialService.SigningCredentials.Add(credentials); + + // Act & Assert + var ex = await Should.ThrowAsync( + async () => await _signingService.GetSigningCertificateBase64Async(_ct)); + + ex.Message.ShouldBe("Signing credential key is not an X509SecurityKey and cannot be used to extract an X509 certificate for SAML metadata."); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_base64_async_with_no_signing_credential_should_throw_invalid_operation_exception() + { + // Arrange - no credentials added to mock service + + // Act & Assert + var ex = await Should.ThrowAsync( + async () => await _signingService.GetSigningCertificateBase64Async(_ct)); + + ex.Message.ShouldBe("No signing credential available. Configure a signing certificate."); + } + + [Fact] + [Trait("Category", Category)] + public async Task get_signing_certificate_base64_async_should_export_public_key_only() + { + // Arrange + var cert = CreateTestCertificate(); + var credentials = new SigningCredentials(new X509SecurityKey(cert), SecurityAlgorithms.RsaSha256); + _mockKeyMaterialService.SigningCredentials.Add(credentials); + + // Act + var result = await _signingService.GetSigningCertificateBase64Async(_ct); + var bytes = Convert.FromBase64String(result); + var exportedCert = X509CertificateLoader.LoadCertificate(bytes); + + // Assert + exportedCert.HasPrivateKey.ShouldBeFalse(); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/SecureXmlParserTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/SecureXmlParserTests.cs new file mode 100644 index 000000000..1b1650acb --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/SecureXmlParserTests.cs @@ -0,0 +1,324 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Xml; +using Duende.IdentityServer.Internal.Saml.Infrastructure; + +namespace UnitTests.Saml; + +/// +/// Security tests for SecureXmlParser to ensure protection against common XML attacks +/// +public class SecureXmlParserTests +{ + private const string Category = "Secure XML Parser"; + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_valid_xml_should_succeed() + { + // Arrange + var validXml = "value"; + + // Act + var doc = SecureXmlParser.LoadXmlDocument(validXml); + + // Assert + doc.ShouldNotBeNull(); + doc.DocumentElement.ShouldNotBeNull(); + doc.DocumentElement!.Name.ShouldBe("root"); + doc.SelectSingleNode("//child")!.InnerText.ShouldBe("value"); + } + + [Fact] + [Trait("Category", Category)] + public void load_x_element_with_valid_xml_should_succeed() + { + // Arrange + var validXml = "value"; + + // Act + var element = SecureXmlParser.LoadXElement(validXml); + + // Assert + element.ShouldNotBeNull(); + element.Name.LocalName.ShouldBe("root"); + element.Element("child")!.Value.ShouldBe("value"); + } + + [Fact] + [Trait("Category", Category)] + public void load_x_document_with_valid_xml_should_succeed() + { + // Arrange + var validXml = "value"; + + // Act + var doc = SecureXmlParser.LoadXDocument(validXml); + + // Assert + doc.ShouldNotBeNull(); + doc.Root.ShouldNotBeNull(); + doc.Root!.Name.LocalName.ShouldBe("root"); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_null_xml_should_throw_argument_null_exception() => + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXmlDocument(null!)); + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_empty_xml_should_throw_argument_null_exception() => + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXmlDocument(string.Empty)); + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_xxe_attack_should_throw_xml_exception() + { + // Arrange - XXE attack attempting to read local file + var xxeAttack = @" + +]> +&xxe;"; + + // Act & Assert + var exception = Should.Throw(() => + SecureXmlParser.LoadXmlDocument(xxeAttack)); + + exception.Message.ShouldContain("prohibited constructs"); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_dtd_attack_should_throw_xml_exception() + { + // Arrange - DTD declaration should be prohibited + var dtdAttack = @" + +]> +content"; + + // Act & Assert + var exception = Should.Throw(() => + SecureXmlParser.LoadXmlDocument(dtdAttack)); + + exception.Message.ShouldContain("prohibited constructs"); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_billion_laughs_attack_should_throw_xml_exception() + { + // Arrange - Billion laughs (entity expansion) attack + var billionLaughsAttack = @" + + + + +]> +&lol4;"; + + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXmlDocument(billionLaughsAttack)); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_external_entity_reference_should_throw_xml_exception() + { + // Arrange - External entity reference attack + var externalEntityAttack = @" + +]> +&external;"; + + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXmlDocument(externalEntityAttack)); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_message_exceeding_max_size_should_throw_xml_exception() + { + // Arrange - Create XML larger than 1MB + var largeContent = new string('X', SecureXmlParser.MaxMessageSize + 1); + var largeXml = $"{largeContent}"; + + // Act & Assert + var exception = Should.Throw(() => + SecureXmlParser.LoadXmlDocument(largeXml)); + + exception.Message.ShouldContain("exceeds maximum allowed size"); + exception.Message.ShouldContain(SecureXmlParser.MaxMessageSize.ToString()); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_comments_should_ignore_comments() + { + // Arrange - XML with comments + var xmlWithComments = @" + + value + +"; + + // Act + var doc = SecureXmlParser.LoadXmlDocument(xmlWithComments); + + // Assert + doc.ShouldNotBeNull(); + // Comments should be ignored (not present in the document) + var comments = doc.SelectNodes("//comment()"); + comments.ShouldNotBeNull(); + comments!.Count.ShouldBe(0); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_processing_instructions_should_ignore_processing_instructions() + { + // Arrange - XML with processing instructions + var xmlWithPI = @" + + + value +"; + + // Act + var doc = SecureXmlParser.LoadXmlDocument(xmlWithPI); + + // Assert + doc.ShouldNotBeNull(); + // Processing instructions should be ignored + var pis = doc.SelectNodes("//processing-instruction()"); + pis.ShouldNotBeNull(); + pis!.Count.ShouldBe(0); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_malformed_xml_should_throw_xml_exception() + { + // Arrange + var malformedXml = ""; + + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXmlDocument(malformedXml)); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_with_saml_response_should_succeed() + { + // Arrange - Real SAML Response structure + var samlResponse = @" + https://idp.example.com + + + + + https://idp.example.com + + user@example.com + + +"; + + // Act + var doc = SecureXmlParser.LoadXmlDocument(samlResponse); + + // Assert + doc.ShouldNotBeNull(); + doc.DocumentElement!.LocalName.ShouldBe("Response"); + } + + [Fact] + [Trait("Category", Category)] + public void load_xml_document_preserves_whitespace() + { + // Arrange - XML with specific whitespace (important for signatures) + var xmlWithWhitespace = @" + value +"; + + // Act + var doc = SecureXmlParser.LoadXmlDocument(xmlWithWhitespace); + + // Assert + doc.ShouldNotBeNull(); + doc.PreserveWhitespace.ShouldBeTrue(); + // Whitespace should be preserved + doc.InnerXml.ShouldContain("\n"); + } + + [Fact] + [Trait("Category", Category)] + public void load_x_element_with_xxe_attack_should_throw_xml_exception() + { + // Arrange + var xxeAttack = @" + +]> +&xxe;"; + + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXElement(xxeAttack)); + } + + [Fact] + [Trait("Category", Category)] + public void load_x_document_with_billion_laughs_attack_should_throw_xml_exception() + { + // Arrange + var billionLaughsAttack = @" + + +]> +&lol2;"; + + // Act & Assert + Should.Throw(() => + SecureXmlParser.LoadXDocument(billionLaughsAttack)); + } + + [Fact] + [Trait("Category", Category)] + public void load_x_element_with_message_exceeding_max_size_should_throw_xml_exception() + { + // Arrange + var largeContent = new string('X', SecureXmlParser.MaxMessageSize + 1); + var largeXml = $"{largeContent}"; + + // Act & Assert + var exception = Should.Throw(() => + SecureXmlParser.LoadXElement(largeXml)); + + exception.Message.ShouldContain("exceeds maximum allowed size"); + } + + [Fact] + [Trait("Category", Category)] + public void max_message_size_should_be_1_mb() => + // Assert + SecureXmlParser.MaxMessageSize.ShouldBe(1048576); +} diff --git a/identity-server/test/IdentityServer.UnitTests/Saml/XmlSignatureHelperTests.cs b/identity-server/test/IdentityServer.UnitTests/Saml/XmlSignatureHelperTests.cs new file mode 100644 index 000000000..f3cd111b8 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Saml/XmlSignatureHelperTests.cs @@ -0,0 +1,346 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Xml; +using System.Xml.Linq; +using Duende.IdentityServer.Internal.Saml; +using Duende.IdentityServer.Internal.Saml.Infrastructure; +using Duende.IdentityServer.Internal.Saml.SingleSignin.Models; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; + +namespace UnitTests.Saml; + +public class XmlSignatureHelperTests +{ + private const string Category = "XML Signature Helper"; + + private readonly ISamlResultSerializer _responseSerializer = new SamlResponse.Serializer(); + + private readonly SamlServiceProvider _samlServiceProvider = new SamlServiceProvider + { + EntityId = "https://sp.example.com", + DisplayName = "Test Service Provider", + AssertionConsumerServiceUrls = [new Uri("https://sp.example.com/saml/acs")] + }; + + private static X509Certificate2 CreateTestCertificate() + { + // Create a self-signed certificate for testing + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Test IdP", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + + // Export and re-import to ensure private key is available + var exported = cert.Export(X509ContentType.Pfx, "test"); + return X509CertificateLoader.LoadPkcs12(exported, "test", X509KeyStorageFlags.Exportable); + } + + [Fact] + [Trait("Category", Category)] + public void sign_response_valid_response_adds_signature() + { + var response = new SamlResponse + { + ServiceProvider = _samlServiceProvider, + IssueInstant = DateTime.UtcNow, + Issuer = "https://idp.example.com", + Destination = new Uri("https://sp.example.com/acs"), + Status = new Status + { + StatusCode = SamlStatusCodes.Success + }, + }; + + var responseElement = _responseSerializer.Serialize(response); + var cert = CreateTestCertificate(); + + var signedXml = XmlSignatureHelper.SignResponse(responseElement, cert); + + signedXml.ShouldContain("Signature"); + signedXml.ShouldContain("SignatureValue"); + signedXml.ShouldContain("X509Certificate"); + + signedXml.ShouldContain("(() => + XmlSignatureHelper.SignResponse(responseElement, cert)) + .Message.ShouldContain("ID attribute"); + } + + [Fact] + [Trait("Category", Category)] + public void sign_response_invalid_element_throws_exception() + { + var invalidElement = new XElement("SomethingElse", + new XAttribute("ID", "_test")); + var cert = CreateTestCertificate(); + + Should.Throw(() => + XmlSignatureHelper.SignResponse(invalidElement, cert)) + .Message.ShouldContain("Response"); + } + + [Fact] + [Trait("Category", Category)] + public void sign_response_null_certificate_throws_exception() + { + var response = new SamlResponse + { + ServiceProvider = _samlServiceProvider, + IssueInstant = DateTime.UtcNow, + Issuer = "https://idp.example.com", + Destination = new Uri("https://sp.example.com/acs"), + Status = new Status { StatusCode = SamlStatusCodes.Success } + }; + + var responseElement = _responseSerializer.Serialize(response); + + Should.Throw(() => + XmlSignatureHelper.SignResponse(responseElement, null!)); + } + + [Fact] + [Trait("Category", Category)] + public void sign_assertion_in_response_no_assertion_throws_exception() + { + var response = new SamlResponse + { + ServiceProvider = _samlServiceProvider, + IssueInstant = DateTime.UtcNow, + Issuer = "https://idp.example.com", + Destination = new Uri("https://sp.example.com/acs"), + Status = new Status { StatusCode = SamlStatusCodes.Success } + // No Assertion! + }; + + var responseElement = _responseSerializer.Serialize(response); + var cert = CreateTestCertificate(); + + Should.Throw(() => + XmlSignatureHelper.SignAssertionInResponse(responseElement, cert)) + .Message.ShouldContain("Assertion"); + } +} diff --git a/identity-server/test/IdentityServer.UnitTests/Services/Default/DefaultIdentityServerInteractionServiceTests.cs b/identity-server/test/IdentityServer.UnitTests/Services/Default/DefaultIdentityServerInteractionServiceTests.cs index 33c387aa0..9fe6a01ac 100644 --- a/identity-server/test/IdentityServer.UnitTests/Services/Default/DefaultIdentityServerInteractionServiceTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Services/Default/DefaultIdentityServerInteractionServiceTests.cs @@ -5,6 +5,7 @@ using Duende.IdentityServer; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml.Models; using Duende.IdentityServer.Services; using Duende.IdentityServer.Stores; using Duende.IdentityServer.Validation; @@ -174,4 +175,80 @@ public class DefaultIdentityServerInteractionServiceTests var consentRequest = new ConsentRequest(req, "bob"); _mockConsentStore.Messages.First().Key.ShouldBe(consentRequest.Id); } + + [Fact] + public async Task CreateLogoutContextAsync_with_saml_sessions_only_should_create_context() + { + _mockUserSession.User = new IdentityServerUser("123").CreatePrincipal(); + _mockUserSession.SessionId = "session"; + _mockUserSession.SamlSessions.Add(new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "idx1", + NameId = "user123" + }); + + var context = await _subject.CreateLogoutContextAsync(_ct); + + context.ShouldNotBeNull(); + _mockLogoutMessageStore.Messages.ShouldNotBeEmpty(); + var message = _mockLogoutMessageStore.Messages[context]; + message.Data.SamlSessions.ShouldNotBeNull(); + message.Data.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp1.example.com"); + message.Data.ClientIds.ShouldBeEmpty(); + } + + [Fact] + public async Task CreateLogoutContextAsync_with_mixed_sessions_should_include_both() + { + _mockUserSession.User = new IdentityServerUser("123").CreatePrincipal(); + _mockUserSession.SessionId = "session"; + _mockUserSession.Clients.Add("client1"); + _mockUserSession.Clients.Add("client2"); + _mockUserSession.SamlSessions.Add(new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + SessionIndex = "idx1", + NameId = "user123" + }); + _mockUserSession.SamlSessions.Add(new SamlSpSessionData + { + EntityId = "https://sp2.example.com", + SessionIndex = "idx2", + NameId = "user123" + }); + + var context = await _subject.CreateLogoutContextAsync(_ct); + + context.ShouldNotBeNull(); + _mockLogoutMessageStore.Messages.ShouldNotBeEmpty(); + var message = _mockLogoutMessageStore.Messages[context]; + + message.Data.ClientIds.ShouldNotBeNull(); + message.Data.ClientIds.Count().ShouldBe(2); + message.Data.ClientIds.ShouldContain("client1"); + message.Data.ClientIds.ShouldContain("client2"); + + message.Data.SamlSessions.ShouldNotBeNull(); + message.Data.SamlSessions.Count().ShouldBe(2); + message.Data.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp1.example.com"); + message.Data.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp2.example.com"); + } + + [Fact] + public async Task CreateLogoutContextAsync_with_oidc_sessions_should_not_populate_saml() + { + _mockUserSession.User = new IdentityServerUser("123").CreatePrincipal(); + _mockUserSession.SessionId = "session"; + _mockUserSession.Clients.Add("client1"); + + var context = await _subject.CreateLogoutContextAsync(_ct); + + context.ShouldNotBeNull(); + _mockLogoutMessageStore.Messages.ShouldNotBeEmpty(); + var message = _mockLogoutMessageStore.Messages[context]; + message.Data.ClientIds?.ShouldContain("client1"); + + message.Data.SamlSessions.ShouldBeEmpty(); + } } diff --git a/identity-server/test/IdentityServer.UnitTests/Validation/EndSessionRequestValidation/EndSessionRequestValidatorTests.cs b/identity-server/test/IdentityServer.UnitTests/Validation/EndSessionRequestValidation/EndSessionRequestValidatorTests.cs index 1383f27ba..d55637d40 100644 --- a/identity-server/test/IdentityServer.UnitTests/Validation/EndSessionRequestValidation/EndSessionRequestValidatorTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Validation/EndSessionRequestValidation/EndSessionRequestValidatorTests.cs @@ -8,6 +8,8 @@ using Duende.IdentityServer; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Models; +using Duende.IdentityServer.Saml; +using Duende.IdentityServer.Saml.Models; using Duende.IdentityServer.Validation; using UnitTests.Common; @@ -22,6 +24,7 @@ public class EndSessionRequestValidatorTests private StubRedirectUriValidator _stubRedirectUriValidator = new StubRedirectUriValidator(); private MockUserSession _userSession = new MockUserSession(); private MockLogoutNotificationService _mockLogoutNotificationService = new MockLogoutNotificationService(); + private MockSamlLogoutNotificationService _mockSamlLogoutNotificationService = new MockSamlLogoutNotificationService(); private MockMessageStore _mockEndSessionMessageStore = new MockMessageStore(); private ClaimsPrincipal _user; @@ -37,6 +40,7 @@ public class EndSessionRequestValidatorTests _stubRedirectUriValidator, _userSession, _mockLogoutNotificationService, + _mockSamlLogoutNotificationService, _mockEndSessionMessageStore, TestLogger.Create()); } @@ -178,4 +182,286 @@ public class EndSessionRequestValidatorTests result.IsError.ShouldBeFalse(); result.ValidatedRequest.Raw.ShouldBeSameAs(parameters); } + + [Fact] + public async Task successful_request_with_saml_sessions_should_populate_saml_sessions() + { + _userSession.User = _user; + _userSession.SamlSessions = + [ + new() { EntityId = "https://sp1.example.com", SessionIndex = "idx1", NameId = "user1" }, + new() { EntityId = "https://sp2.example.com", SessionIndex = "idx2", NameId = "user1" } + ]; + + var parameters = new NameValueCollection(); + + var result = await _subject.ValidateAsync(parameters, _user, _ct); + + result.IsError.ShouldBeFalse(); + result.ValidatedRequest.SamlSessions.ShouldNotBeNull(); + result.ValidatedRequest.SamlSessions.Count().ShouldBe(2); + result.ValidatedRequest.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp1.example.com"); + result.ValidatedRequest.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp2.example.com"); + } + + [Fact] + public async Task successful_request_without_saml_sessions_should_have_empty_saml_sessions() + { + _userSession.User = _user; + _userSession.SamlSessions = []; + + var parameters = new NameValueCollection(); + + var result = await _subject.ValidateAsync(parameters, _user, _ct); + + result.IsError.ShouldBeFalse(); + result.ValidatedRequest.SamlSessions.ShouldNotBeNull(); + result.ValidatedRequest.SamlSessions.ShouldBeEmpty(); + } + + [Fact] + public async Task successful_request_with_both_oidc_and_saml_sessions_should_populate_both() + { + _userSession.User = _user; + _userSession.Clients = ["client1", "client2"]; + _userSession.SamlSessions = + [ + new() { EntityId = "https://sp1.example.com", SessionIndex = "idx1", NameId = "user1" }, + new() { EntityId = "https://sp2.example.com", SessionIndex = "idx2", NameId = "user1" } + ]; + + var parameters = new NameValueCollection(); + + var result = await _subject.ValidateAsync(parameters, _user, _ct); + + result.IsError.ShouldBeFalse(); + + // OIDC clients + result.ValidatedRequest.ClientIds.ShouldNotBeNull(); + result.ValidatedRequest.ClientIds.Count().ShouldBe(2); + result.ValidatedRequest.ClientIds.ShouldContain("client1"); + result.ValidatedRequest.ClientIds.ShouldContain("client2"); + + // SAML SPs + result.ValidatedRequest.SamlSessions.ShouldNotBeNull(); + result.ValidatedRequest.SamlSessions.Count().ShouldBe(2); + result.ValidatedRequest.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp1.example.com"); + result.ValidatedRequest.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp2.example.com"); + } + + [Fact] + public async Task successful_request_with_id_token_hint_should_collect_saml_sessions() + { + _stubTokenValidator.IdentityTokenValidationResult = new TokenValidationResult() + { + IsError = false, + Claims = [new Claim("sub", _user.GetSubjectId())], + Client = new Client() { ClientId = "client" } + }; + _userSession.User = _user; + _userSession.SamlSessions = + [ + new() { EntityId = "https://sp1.example.com", SessionIndex = "idx1", NameId = "user1" } + ]; + + var parameters = new NameValueCollection(); + parameters.Add("id_token_hint", "id_token"); + + var result = await _subject.ValidateAsync(parameters, _user, _ct); + + result.IsError.ShouldBeFalse(); + result.ValidatedRequest.SamlSessions.ShouldNotBeNull(); + result.ValidatedRequest.SamlSessions.Select(s => s.EntityId).ShouldContain("https://sp1.example.com"); + } + + [Fact] + public async Task validate_callback_async_with_only_saml_service_providers_return_success() + { + var context = new LogoutNotificationContext + { + SubjectId = "test", + SessionId = "session123", + ClientIds = [], + SamlSessions = + [ + new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session123" + } + ] + }; + _mockEndSessionMessageStore.Messages["endSessionId123"] = new Message(context, DateTime.UtcNow); + + var samlLogout = new MockSamlFrontChannelLogout(); + _mockSamlLogoutNotificationService.SamlFrontChannelLogouts.Add(samlLogout); + + var parameters = new NameValueCollection + { + { "endSessionId", "endSessionId123" } + }; + + var result = await _subject.ValidateCallbackAsync(parameters, _ct); + + result.IsError.ShouldBeFalse(); + result.SamlFrontChannelLogouts.ShouldNotBeNull(); + result.SamlFrontChannelLogouts.ShouldHaveSingleItem(); + _mockSamlLogoutNotificationService.GetSamlFrontChannelLogoutsAsyncCalled.ShouldBeTrue(); + } + + [Fact] + public async Task validate_callback_async_with_both_oidc_and_saml_returns_both() + { + var context = new LogoutNotificationContext + { + SubjectId = "test", + SessionId = "session123", + ClientIds = ["client1"], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + } + ] + }; + _mockEndSessionMessageStore.Messages["endSessionId123"] = new Message(context, DateTime.UtcNow); + + _mockLogoutNotificationService.FrontChannelLogoutNotificationsUrls.Add("http://client1.com/logout"); + var samlLogout = new MockSamlFrontChannelLogout(); + _mockSamlLogoutNotificationService.SamlFrontChannelLogouts.Add(samlLogout); + + var parameters = new NameValueCollection + { + { "endSessionId", "endSessionId123" } + }; + + var result = await _subject.ValidateCallbackAsync(parameters, _ct); + + result.IsError.ShouldBeFalse(); + result.FrontChannelLogoutUrls.ShouldHaveSingleItem(); + result.SamlFrontChannelLogouts.ShouldHaveSingleItem(); + } + + [Fact] + public async Task validate_callback_async_with_only_saml_empty_list_returns_error() + { + var context = new LogoutNotificationContext + { + SubjectId = "test", + SessionId = "session123", + ClientIds = [], + SamlSessions = [] + }; + _mockEndSessionMessageStore.Messages["endSessionId123"] = new Message(context, DateTime.UtcNow); + + var parameters = new NameValueCollection + { + { "endSessionId", "endSessionId123" } + }; + + var result = await _subject.ValidateCallbackAsync(parameters, _ct); + + result.IsError.ShouldBeTrue(); + } + + [Fact] + public async Task validate_callback_async_with_saml_passes_context_to_saml_notification_service() + { + var context = new LogoutNotificationContext + { + SubjectId = "test_user", + SessionId = "session123", + ClientIds = [], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + }, + new SamlSpSessionData + { + EntityId = "https://sp2.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session2" + } + ] + }; + _mockEndSessionMessageStore.Messages["endSessionId123"] = new Message(context, DateTime.UtcNow); + + _mockSamlLogoutNotificationService.SamlFrontChannelLogouts.Add(new MockSamlFrontChannelLogout()); + + var parameters = new NameValueCollection + { + { "endSessionId", "endSessionId123" } + }; + + await _subject.ValidateCallbackAsync(parameters, _ct); + + _mockSamlLogoutNotificationService.GetSamlFrontChannelLogoutsAsyncCalled.ShouldBeTrue(); + } + + [Fact] + public async Task validate_callback_async_with_multiple_saml_service_providers_returns_all() + { + var context = new LogoutNotificationContext + { + SubjectId = "test", + SessionId = "session123", + ClientIds = [], + SamlSessions = [ + new SamlSpSessionData + { + EntityId = "https://sp1.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session1" + }, + new SamlSpSessionData + { + EntityId = "https://sp2.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session2" + }, + new SamlSpSessionData + { + EntityId = "https://sp3.example.com", + NameId = "user@example.com", + NameIdFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + SessionIndex = "session3" + } + ] + }; + _mockEndSessionMessageStore.Messages["endSessionId123"] = new Message(context, DateTime.UtcNow); + + _mockSamlLogoutNotificationService.SamlFrontChannelLogouts.Add(new MockSamlFrontChannelLogout()); + _mockSamlLogoutNotificationService.SamlFrontChannelLogouts.Add(new MockSamlFrontChannelLogout()); + _mockSamlLogoutNotificationService.SamlFrontChannelLogouts.Add(new MockSamlFrontChannelLogout()); + + var parameters = new NameValueCollection + { + { "endSessionId", "endSessionId123" } + }; + + var result = await _subject.ValidateCallbackAsync(parameters, _ct); + + result.IsError.ShouldBeFalse(); + result.SamlFrontChannelLogouts.Count().ShouldBe(3); + } + + private class MockSamlFrontChannelLogout : ISamlFrontChannelLogout + { + public SamlBinding SamlBinding => SamlBinding.HttpRedirect; + public Uri Destination => new Uri("https://sp.example.com/slo"); + public string EncodedContent => "encoded"; + public string RelayState => null; + } } diff --git a/products.slnx b/products.slnx index bd7db6cfd..3707b838f 100644 --- a/products.slnx +++ b/products.slnx @@ -132,6 +132,7 @@ + @@ -165,6 +166,7 @@ +