asp.net-web-api.net-6.0samlsaml-2.0adfs

How to validate a SAML token from ADFS 3.0 in an ASP.NET Core Web API


I am trying to validate a SAML token that is sent as a header to a ASP.NET Core 6 Web API. Frankly, I do not have a lot of experience working with tokens, so I probably lack some foundational knowledge that would be helpful here. Any guidance is appreciated.

Tokens are acquired from a .NET Framework service like which obtains them from our ADFS 3.0 instance on Windows Server 2012R2 using code like the following:

WSTrustChannelFactory trustChannelFactory =
    new WSTrustChannelFactory(new WindowsWSTrustBinding(SecurityMode.TransportWithMessageCredential),
        new EndpointAddress(new Uri(url)));

trustChannelFactory.TrustVersion = TrustVersion.WSTrust13;
trustChannelFactory.Credentials.Windows.ClientCredential.Domain = domain;
trustChannelFactory.Credentials.Windows.ClientCredential.UserName = username;
trustChannelFactory.Credentials.Windows.ClientCredential.Password = password;

RequestSecurityToken rst = new RequestSecurityToken(WSTrust13Constants.RequestTypes.Issue, WSTrust13Constants.KeyTypes.Bearer);
rst.AppliesTo = new EndpointAddress(relyingParting);
rst.TokenType = Microsoft.IdentityModel.Tokens.SecurityTokenTypes.Saml2TokenProfile11;
WSTrustChannel channel = (WSTrustChannel)trustChannelFactory.CreateChannel();
GenericXmlSecurityToken token = channel.Issue(rst) as GenericXmlSecurityToken;
tokenString = token.TokenXml.OuterXml;

return tokenString;

The token comes out looking something like this:

<Assertion ID="_1d020948-dd8c-4349-9b4e-94832d2381b3" IssueInstant="2024-04-29T19:18:26.948Z" Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion">
    <Issuer>http://my-adfs-instance.net/adfs/services/trust</Issuer>
    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
        <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
        <ds:Reference URI="#_1d020948-dd8c-4349-9b4e-94832d2381b3">
            <ds:Transforms>
                <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
                <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
            </ds:Transforms>
            <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
            <ds:DigestValue>NoClueIfSensitive</ds:DigestValue>
        </ds:Reference>
        </ds:SignedInfo>
        <ds:SignatureValue>Redacted</ds:SignatureValue>
        <KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
            <ds:X509Data>
                <ds:X509Certificate>MII...</ds:X509Certificate>
            </ds:X509Data>
        </KeyInfo>
    </ds:Signature>
    <Subject>
        <SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
            <SubjectConfirmationData NotOnOrAfter="2024-04-29T19:23:26.948Z" />
        </SubjectConfirmation>
    </Subject>
    <Conditions NotBefore="2024-04-29T19:18:26.653Z" NotOnOrAfter="2024-04-29T20:18:26.653Z">
        <AudienceRestriction>
            <Audience>https://my-app.net</Audience>
        </AudienceRestriction>
    </Conditions>
    <AttributeStatement>
        ...
    </AttributeStatement>
    <AuthnStatement AuthnInstant="2024-04-29T19:18:26.590Z">
        ...
    </AuthnStatement>
</Assertion>

I've abbreviated some fields for brevity since its already pretty long - just let me know if those things are important for an answer.

That token is sent over as a header to the ASP.NET Core Web API I mentioned.

I have tried a couple different ways of validating this token.

Ideally, I'd like to use a Microsoft.IdentityModel.Tokens.Saml2.Saml2SecurityTokenHandler so that I can leverage the ValidateToken method that comes with it like so:

var headers = Request.Headers;
var token = headers["token"];

var tokenHandler = new Saml2SecurityTokenHandler();
var tokenValidationParameters = new TokenValidationParameters()
{
    ValidIssuer = _config.GetSection("TokenValidation")["ValidIssuer"],
    ValidAudience = _config.GetSection("TokenValidation")["ValidAudience"]
};

tokenHandler.ValidateToken(token, tokenValidationParameters, out SecurityToken validatedToken);

This fails, which I believe is to be expected, with a SecurityTokenSignatureKeyNotFoundException exception that states

IDX10500: Signature validation failed. No security keys were provided to validate the signature

After rummaging around documentation, I then tried the IssuerSigningKey property out by pulling down the code-signing cert from my ADFS instance and placing it on my local machine. Not a perfect solution, but just trying to get some kind of successful test at this point:

var tokenHandler = new Saml2SecurityTokenHandler();
var issuerSigningCert = new X509Certificate2("C:\\signingCert.cer");
var securityKey = new X509SecurityKey(issuerSigningCert);
var tokenValidationParameters = new TokenValidationParameters()
{
    ValidIssuer = _config.GetSection("TokenValidation")["ValidIssuer"],
    ValidAudience = _config.GetSection("TokenValidation")["ValidAudience"],
    IssuerSigningKey = securityKey
};

tokenHandler.ValidateToken(token, tokenValidationParameters, out SecurityToken validatedToken);

This results in a SecurityTokenInvalidSignatureException with message

IDX10514: Signature validation failed

I also tried plugging in the PFX instead of just the cer by changing the file path to the pfx and supplying the password in the X509Certificate2 constructor, but I received the same exception and message.

I did notice something interesting when trying to use the ReadSaml2Token method:

var tokenHandler = new Saml2SecurityTokenHandler();
using (XmlReader reader = XmlReader.Create(new StringReader(token)))
{
    var tokenReadIn = tokenHandler.ReadSaml2Token(reader);
}

null-properties

The SecurityKey and SigningKey are always null. This makes me wonder if there is some issue with my tokens in particular that they aren't being parsed correctly, and this is where I am stuck with this route.

I have a separate .NET Framework 4.5 API that is able to receive and validate tokens using the following code

Microsoft.IdentityModel.Configuration.ServiceConfiguration serviceConfig
       = new Microsoft.IdentityModel.Configuration.ServiceConfiguration();

SecurityToken theToken = null;
ClaimsIdentityCollection identity = null;
using (XmlReader reader = XmlReader.Create(new StringReader(authSamlString)))
{
    theToken = serviceConfig.SecurityTokenHandlers.ReadToken(reader);
    identity = serviceConfig.SecurityTokenHandlers.ValidateToken(theToken);
}

The packages used here are System.IdentityModel.Tokens and Microsoft.IdentityModel.Claims and they don't appear to be intended for .NET Core/.NET so I wasn't able to replicate that exactly, but I don't see any specific customization or configuration other than just the ValidateToken call in this application.

The alternate route I took involved using SignedXml to load in the Signature, specify the cert public key, and attempt to check with CheckSignature, very similar to the answer posted here Asp.Net Core SAML Response Signature Validation.

This was quite a mess, and I'd greatly prefer to avoid that route unless it's my only option. No matter what I tried with that route, I also could not get anything other then a false result from CheckSignature. I can post the actual code I used if that seems necessary, but I'd like to see if I can get the Microsoft.IdentityModel solution working instead.

Any and all help is greatly appreciated. Upgrading ADFS is unfortunately not feasible, but if there are any configuration changes or alternate approaches in general, those are fine.

EDIT #1: I tried an alternate way to specify the IssuerSigningKey:

var issuerSigningCert = new X509Certificate2("C:\\signingCert.cer");
var securityKey = new X509SecurityKey(issuerSigningCert);
var tokenValidationParameters = new TokenValidationParameters()
{
    ValidIssuer = _config.GetSection("TokenValidation")["ValidIssuer"],
    ValidAudience = _config.GetSection("TokenValidation")["ValidAudience"],
    IssuerSigningKey = new SymmetricSecurityKey(issuerSigningCert.GetPublicKey())
};

When I try to validate with this setup, I get an exception that states:

Message: IDX10512: Signature validation failed. Token does not have KeyInfo.

Even though my token does have KeyInfo within it. When I review the token after calling ReadSaml2Token, here is what I see: enter image description here

Everything is null except for the cert in the X509Data property.

Edit #2: It looks like IDX10512 is possibly a red-herring. I turned on ShowPII and my exception message grew to

Message "IDX10512: Signature validation failed. Token does not have KeyInfo. Keys tried: 'Microsoft.IdentityModel.Tokens.SymmetricSecurityKey, KeyId: '', InternalId: 'ABCD...'. , KeyId: \r\n'.\nExceptions caught:\n 'Microsoft.IdentityModel.Xml.XmlValidationException: IDX30207: SignatureMethod is not supported: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'. CryptoProviderFactory: 'Microsoft.IdentityModel.Tokens.CryptoProviderFactory'.\r\n at Microsoft.IdentityModel.Xml.Signature.Verify(SecurityKey key, CryptoProviderFactory cryptoProviderFactory)\r\n at Microsoft.IdentityModel.Tokens.Saml2.Saml2SecurityTokenHandler.ValidateSignature(Saml2SecurityToken samlToken, String token, TokenValidationParameters validationParameters)\r\n'.\ntoken: '[Security Artifact of type 'Microsoft.IdentityModel.Tokens.Saml2.Saml2SecurityToken' is hidden. For more details, see https://aka.ms/IdentityModel/SecurityArtifactLogging.]'." string

So it appears to be a lack of support for rsa-sha256? No idea how to work around this one yet...


Solution

  • Well I will never get 25 hours of my life back but I did learn a valuable lesson.

    There was nothing wrong with pretty much anything other than how I was sending the token for testing. My token source is a .NET Framework SOAP API and tends to spit things out a little funky. enter image description here

    This doesn't seem to happen when a program retrieves the token and sends a request, but when I am testing manually and obtain the token thru Postman, it does. To counter this issue in general for all requests, in my infinite wisdom, I wrote a tiny helper method to do string replacements on &lt; to < and &gt; to >. Apparently this is wildly unsafe, for reasons I am not yet sure of, though I am sure they are reasonable.

    I finally stumbled on the appropriate way to do this which is System.Net.WebUtility.HtmlDecode and would you look at that, I am able to validate my token.

    At the end of the day, I guess the lesson is to work smarter and not harder. Working code sample:

    var decodedToken = System.Net.WebUtility.HtmlDecode(token);
    var tokenHandler = new Saml2SecurityTokenHandler();
    var issuerSigningCert = new X509Certificate2("C:\\signingCert.cer");
    var securityKey = new X509SecurityKey(issuerSigningCert);
    var tokenValidationParameters = new TokenValidationParameters()
    {
        ValidIssuer = _config.GetSection("TokenValidation")["ValidIssuer"],
        ValidAudience = _config.GetSection("TokenValidation")["ValidAudience"],
        IssuerSigningKey = securityKey
    };
    
    var identity = tokenHandler.ValidateToken(decodedToken , tokenValidationParameters, out SecurityToken validatedToken);
    

    I'll likely update my code to either pull the cert from a machine store or reach out to my ADFS instance for it directly, but you get the idea here.