diff --git a/src/Microsoft.IdentityModel.Tokens.Saml/Saml/SamlSecurityTokenHandler.cs b/src/Microsoft.IdentityModel.Tokens.Saml/Saml/SamlSecurityTokenHandler.cs index 1be8c0f1c7..e65e8bc09d 100644 --- a/src/Microsoft.IdentityModel.Tokens.Saml/Saml/SamlSecurityTokenHandler.cs +++ b/src/Microsoft.IdentityModel.Tokens.Saml/Saml/SamlSecurityTokenHandler.cs @@ -1275,8 +1275,7 @@ private ClaimsPrincipal ValidateToken(SamlSecurityToken samlToken, string token, ValidateConditions(samlToken, validationParameters); var issuer = ValidateIssuer(samlToken.Issuer, samlToken, validationParameters); - if (samlToken.Assertion.Conditions != null) - ValidateTokenReplay(samlToken.Assertion.Conditions.NotOnOrAfter, samlToken.Assertion.CanonicalString, validationParameters); + ValidateTokenReplay(samlToken.Assertion.Conditions?.NotOnOrAfter, samlToken.Assertion.CanonicalString, validationParameters); ValidateIssuerSecurityKey(samlToken.SigningKey, samlToken, validationParameters); validatedToken = samlToken; diff --git a/src/Microsoft.IdentityModel.Tokens.Saml/Saml2/Saml2SecurityTokenHandler.cs b/src/Microsoft.IdentityModel.Tokens.Saml/Saml2/Saml2SecurityTokenHandler.cs index 2475ef8041..68b8a0703a 100644 --- a/src/Microsoft.IdentityModel.Tokens.Saml/Saml2/Saml2SecurityTokenHandler.cs +++ b/src/Microsoft.IdentityModel.Tokens.Saml/Saml2/Saml2SecurityTokenHandler.cs @@ -274,8 +274,7 @@ private ClaimsPrincipal ValidateToken(Saml2SecurityToken samlToken, string token ValidateSubject(samlToken, validationParameters); var issuer = ValidateIssuer(samlToken.Issuer, samlToken, validationParameters); - if (samlToken.Assertion.Conditions != null) - ValidateTokenReplay(samlToken.Assertion.Conditions.NotOnOrAfter, samlToken.Assertion.CanonicalString, validationParameters); + ValidateTokenReplay(samlToken.Assertion.Conditions?.NotOnOrAfter, samlToken.Assertion.CanonicalString, validationParameters); ValidateIssuerSecurityKey(samlToken.SigningKey, samlToken, validationParameters); validatedToken = samlToken; diff --git a/test/Microsoft.IdentityModel.TestUtils/ReferenceTokens.cs b/test/Microsoft.IdentityModel.TestUtils/ReferenceTokens.cs index 52b1d217bf..65381f4787 100644 --- a/test/Microsoft.IdentityModel.TestUtils/ReferenceTokens.cs +++ b/test/Microsoft.IdentityModel.TestUtils/ReferenceTokens.cs @@ -83,6 +83,9 @@ public class ReferenceTokens public static string Saml2Token_NoConditions_NoSignature = @"https://sts.windows.net/add29489-7269-41f4-8841-b63c95564420/RrX3SPSxDw6z4KHaKB2V_mnv0G-LbRZdYvo1RQa1L7sadd29489-7269-41f4-8841-b63c95564420d1ad9ce7-b322-4221-ab74-1e1011e1bbcbUser1@Cyrano.onmicrosoft.com1UserUser1https://sts.windows.net/add29489-7269-41f4-8841-b63c95564420/urn:oasis:names:tc:SAML:2.0:ac:classes:Password"; + public static string Saml2Token_ConditionsNoExpiration_NoSignature = + @"https://sts.windows.net/add29489-7269-41f4-8841-b63c95564420/RrX3SPSxDw6z4KHaKB2V_mnv0G-LbRZdYvo1RQa1L7sadd29489-7269-41f4-8841-b63c95564420d1ad9ce7-b322-4221-ab74-1e1011e1bbcbUser1@Cyrano.onmicrosoft.com1UserUser1https://sts.windows.net/add29489-7269-41f4-8841-b63c95564420/urn:oasis:names:tc:SAML:2.0:ac:classes:Password"; + public static string Saml2Token_Actor_Claim = @"https://sts.windows.net/add29489-7269-41f4-8841-b63c95564420/RrX3SPSxDw6z4KHaKB2V_mnv0G-LbRZdYvo1RQa1L7sspn:fe78e0b4-6fe7-47e6-812c-fb75cee266a4add29489-7269-41f4-8841-b63c95564420d1ad9ce7-b322-4221-ab74-1e1011e1bbcbUser1@Cyrano.onmicrosoft.com1UserUser1https://sts.windows.net/add29489-7269-41f4-8841-b63c95564420/TestActorTestActorurn:oasis:names:tc:SAML:2.0:ac:classes:Password"; @@ -201,6 +204,9 @@ public static string Saml2Token_Formated public static string SamlToken_NoConditions_NoSignature = @"Boburn:oasis:names:tc:SAML:1.0:cm:bearerUSABob@contoso.comBob555.1212DeveloperSalesJean-Sébastien"; + public static string SamlToken_ConditionsNoExpiration_NoSignature = + @"Boburn:oasis:names:tc:SAML:1.0:cm:bearerUSABob@contoso.comBob555.1212DeveloperSalesJean-Sébastien"; + public static string SamlToken_Valid = @"http://Default.Audience.comBoburn:oasis:names:tc:SAML:1.0:cm:bearerUSABob@contoso.comBob555.1212DeveloperSalesJean-Sébastienz+4mubehpI9sLNVwGGIuy3jiGhP9k+PHiRyO0Mqd/YM=hWd3ALlvaNuHz2JbCnSuVly/pZwtVZzLQwsMvvn03dl6URoFQhvYpldE+6ZpzL77XMrsC0VmPaQbw76fkztK2P/0tp4hzaW///Jdr/E+HcSfG0Cdt+NWuybyGkJljbh0tif6BbxIaDNc/5dx6SoCyItP3IqU5JciwaTsOGQTmcUzoI3lIY9N7pv2ChD3fczWo1O8W+T0Caka69cxhb037HpUWLmjekED9sqBKfLKDsVH8rpef7cGroTILaZ4xOHwOEmrV6xOrCq3erupnLhw5eVC4wDuhL/0KrbfgfTExM5iUnyfrnm+C74M6WnnfqpzWHWuCv10W32W1L8mlQtVZQ==MIIDJTCCAg2gAwIBAgIQGzlg2gNmfKRKBa6dqqZXxzANBgkqhkiG9w0BAQQFADAiMSAwHgYDVQQDExdLZXlTdG9yZVRlc3RDZXJ0aWZpY2F0ZTAeFw0xMTExMDkxODE5MDZaFw0zOTEyMzEyMzU5NTlaMCIxIDAeBgNVBAMTF0tleVN0b3JlVGVzdENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAns1cm8RU1hKZILPI6pB5Zoxn9mW2tSS0atV+o9FCn9NyeOktEOj1kEXOeIz0KfnqxgPMF1GpshuZBAhgjkyy2kNGE6Zx50CCJgq6XUatvVVJpMp8/FV18ynPf+/TRlF8V2HO3IVJ0XqRJ9fGA2f5xpOweWsdLYitdHbaDCl6IBNSXo52iNuqWAcB1k7jBlsnlXpuvslhLIzj60dnghAVA4ltS3NlFyw1Tz3pGlZQDt7x83IBHe7DA9bV3aJs1trkm1NzI1HoRS4vOqU3n4fn+DlfAE2vYKNkSi/PjuAX+1YQCq6e5uN/hOeSEqji8SsWC2nk/bMTKPwD67rn3jNC9wIDAQABo1cwVTBTBgNVHQEETDBKgBA3gSuALjvEuAVmF/x8knXvoSQwIjEgMB4GA1UEAxMXS2V5U3RvcmVUZXN0Q2VydGlmaWNhdGWCEBs5YNoDZnykSgWunaqmV8cwDQYJKoZIhvcNAQEEBQADggEBAFZvDA7PBh/vvFZb/QCBelTyD2Yqij16v3tk30A3Akli6UIILdbbOcA5BiPktT1kJxcsgSXNHUODlfG2Fy9HTqwunr8G7FYniOUXPVrRL+HwhKOzRFDMUS3+On+ZDzum7rbpm3SYlnJDyNb8wynPw/bXQw72jGjt63uh6OnkYE8fJ8iPfVWOenZkP/IXPIXK/bBwLMDJ1y77ZauPYbp7oiQ/991pn0c7F4ugT9LYmbAdJKhiainOaoBTvIHN8/lMZ8gHUuxvOJhPrbgo3NTqvT1/3kfD0AISP4R3pH0QL/0m7cO34nK4rFFLZs1sFUguYUJhfkyq1N8MiyyAqRmrvBQ="; diff --git a/test/Microsoft.IdentityModel.Tokens.Saml.Tests/SamlConditionsHandlingTests.cs b/test/Microsoft.IdentityModel.Tokens.Saml.Tests/SamlConditionsHandlingTests.cs new file mode 100644 index 0000000000..f71c5702fc --- /dev/null +++ b/test/Microsoft.IdentityModel.Tokens.Saml.Tests/SamlConditionsHandlingTests.cs @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Microsoft.IdentityModel.TestUtils; +using Microsoft.IdentityModel.Tokens.Saml2; +using Microsoft.IdentityModel.Tokens.Saml2.Tests; +using Xunit; + +#pragma warning disable CS3016 // Arrays as attribute arguments is not CLS-compliant + +namespace Microsoft.IdentityModel.Tokens.Saml.Tests +{ + /// + /// Verifies that the SAML and SAML2 handlers invoke ValidateTokenReplay + /// uniformly, regardless of whether the assertion contains a Conditions element. + /// + public class SamlConditionsHandlingTests + { + // ———————————————————— SAML 2.0 ———————————————————— + + [Theory, MemberData(nameof(Saml2ConditionsHandlingTheoryData), DisableDiscoveryEnumeration = true)] + public void Saml2ConditionsHandling(Saml2TheoryData theoryData) + { + var context = TestUtilities.WriteHeader($"{this}.Saml2ConditionsHandling", theoryData); + try + { + theoryData.Handler.ValidateToken(theoryData.Token, theoryData.ValidationParameters, out SecurityToken validatedToken); + theoryData.ExpectedException.ProcessNoException(context); + } + catch (Exception ex) + { + theoryData.ExpectedException.ProcessException(ex, context); + } + + TestUtilities.AssertFailIfErrors(context); + } + + public static TheoryData Saml2ConditionsHandlingTheoryData + { + get + { + return new TheoryData + { + // Conditions absent + replay cache configured: replay validation + // still runs and surfaces the missing expiration. + new Saml2TheoryData("Saml2_NoConditions_WithReplayCache_ThrowsNoExpiration") + { + Token = ReferenceTokens.Saml2Token_NoConditions_NoSignature, + ExpectedException = ExpectedException.SecurityTokenNoExpirationException("IDX10227:"), + ValidationParameters = new TokenValidationParameters + { + RequireSignedTokens = false, + RequireAudience = false, + ValidateLifetime = false, + ValidateIssuer = false, + ValidateAudience = false, + ValidateTokenReplay = true, + TokenReplayCache = new TokenReplayCache + { + OnAddReturnValue = true, + OnFindReturnValue = false + } + } + }, + // Token with Conditions and a replay cache continues to validate normally. + new Saml2TheoryData("Saml2_WithConditions_WithReplayCache_Succeeds") + { + Token = ReferenceTokens.Saml2Token_Valid, + ValidationParameters = new TokenValidationParameters + { + IssuerSigningKey = KeyingMaterial.DefaultAADSigningKey, + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + ValidateTokenReplay = true, + TokenReplayCache = new TokenReplayCache + { + OnAddReturnValue = true, + OnFindReturnValue = false + } + } + }, + // No Conditions and no replay cache: validation succeeds because + // replay enforcement is not requested. + new Saml2TheoryData("Saml2_NoConditions_NoReplayCache_Succeeds") + { + Token = ReferenceTokens.Saml2Token_NoConditions_NoSignature, + ValidationParameters = new TokenValidationParameters + { + RequireSignedTokens = false, + RequireAudience = false, + ValidateLifetime = false, + ValidateIssuer = false, + ValidateAudience = false, + } + }, + // Conditions element present but NotOnOrAfter is unset — replay validation + // still runs and surfaces the missing expiration. + new Saml2TheoryData("Saml2_ConditionsNoExpiration_WithReplayCache_ThrowsNoExpiration") + { + Token = ReferenceTokens.Saml2Token_ConditionsNoExpiration_NoSignature, + ExpectedException = ExpectedException.SecurityTokenNoExpirationException("IDX10227:"), + ValidationParameters = new TokenValidationParameters + { + RequireSignedTokens = false, + RequireAudience = false, + ValidateLifetime = false, + ValidateIssuer = false, + ValidateAudience = false, + ValidateTokenReplay = true, + TokenReplayCache = new TokenReplayCache + { + OnAddReturnValue = true, + OnFindReturnValue = false + } + } + }, + }; + } + } + + // ———————————————————— SAML 1.x ———————————————————— + + [Theory, MemberData(nameof(SamlConditionsHandlingTheoryData), DisableDiscoveryEnumeration = true)] + public void SamlConditionsHandling(SamlTheoryData theoryData) + { + var context = TestUtilities.WriteHeader($"{this}.SamlConditionsHandling", theoryData); + try + { + (theoryData.Handler as SamlSecurityTokenHandler).ValidateToken(theoryData.Token, theoryData.ValidationParameters, out SecurityToken validatedToken); + theoryData.ExpectedException.ProcessNoException(context); + } + catch (Exception ex) + { + theoryData.ExpectedException.ProcessException(ex, context); + } + + TestUtilities.AssertFailIfErrors(context); + } + + public static TheoryData SamlConditionsHandlingTheoryData + { + get + { + return new TheoryData + { + // Conditions absent + replay cache configured: replay validation + // still runs and surfaces the missing expiration. + new SamlTheoryData("Saml_NoConditions_WithReplayCache_ThrowsNoExpiration") + { + Token = ReferenceTokens.SamlToken_NoConditions_NoSignature, + ExpectedException = ExpectedException.SecurityTokenNoExpirationException("IDX10227:"), + ValidationParameters = new TokenValidationParameters + { + RequireSignedTokens = false, + RequireAudience = false, + ValidateLifetime = false, + ValidateIssuer = false, + ValidateAudience = false, + ValidateTokenReplay = true, + TokenReplayCache = new TokenReplayCache + { + OnAddReturnValue = true, + OnFindReturnValue = false + } + } + }, + // Token with Conditions and a replay cache continues to validate normally. + new SamlTheoryData("Saml_WithConditions_WithReplayCache_Succeeds") + { + Token = ReferenceTokens.SamlToken_Valid, + ValidationParameters = new TokenValidationParameters + { + IssuerSigningKey = KeyingMaterial.DefaultX509SigningCreds_2048_RsaSha2_Sha2.Key, + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + ValidateTokenReplay = true, + TokenReplayCache = new TokenReplayCache + { + OnAddReturnValue = true, + OnFindReturnValue = false + } + } + }, + // No Conditions and no replay cache: validation succeeds because + // replay enforcement is not requested. + new SamlTheoryData("Saml_NoConditions_NoReplayCache_Succeeds") + { + Token = ReferenceTokens.SamlToken_NoConditions_NoSignature, + ValidationParameters = new TokenValidationParameters + { + RequireSignedTokens = false, + RequireAudience = false, + ValidateLifetime = false, + ValidateIssuer = false, + ValidateAudience = false, + } + }, + // Conditions element present but NotOnOrAfter is unset. SAML 1.x + // defaults to DateTime.MaxValue here, so replay validation succeeds + // with a far-future expiration. + new SamlTheoryData("Saml_ConditionsNoExpiration_WithReplayCache_Succeeds") + { + Token = ReferenceTokens.SamlToken_ConditionsNoExpiration_NoSignature, + ValidationParameters = new TokenValidationParameters + { + RequireSignedTokens = false, + RequireAudience = false, + ValidateLifetime = false, + ValidateIssuer = false, + ValidateAudience = false, + ValidateTokenReplay = true, + TokenReplayCache = new TokenReplayCache + { + OnAddReturnValue = true, + OnFindReturnValue = false + } + } + }, + }; + } + } + } +}