Wednesday, April 13, 2022

Simplest SAML1.1 (WS-Federation) Federated Authentication in .NET 6

Years ago I've blogged on how to do the WS-Fed using code only in .NET. The approach had multiple advantages over the static WSFederationAuthenticationModule (the WSFam) configured in web.config. The most important feature here is that you have the most control over what happens: how the redirect to the Identity Provider is created and how the SAML token response is consumed.
This is extremely useful in complex scenarios, e.g. in multitenant apps where tenants are configured individually or multiple identity providers are allowed or even when the federated authentication is optional at all.
.NET 6 (.NET Core) has its own way of handling WS-Federation, namely, it registers its own middleware (AddWsFederation) that can be controlled with few configuration options. However, I still feel I miss some features, like triggering the flow conditionally in a multitenant app. What I need is not yet another middleware but rather, a slightly more low-level approach following the basic principle: not use module/middleware but rather have a custom code in the logon controller/action:
if ( !IsWsFedResponse() )
{
   RedirectToIdP();
}
else
{
  var token = GetTokenFromResponse();
  var principal = ValidateToken();
  
  AuthenticateUsingPrincipal(principal);
}
Please refer to the blog entry I've linked at the top to see the approach presented there follows this.
Can we have a similar flow in .NET Core? Ignore the middleware but rather have a total control over the WS-Fed?
The answer is: sure. Since the docs are sparse and there are not-that-much examples, a decompiler is handy to just see how things are done internally so we don't rewrite anything that's already there.
So, just setup your cookie authentication and point your unauthenticated users to /Account/Logon. And then:
public class AccountController : Controller
{
	private KeyValuePair Convert(KeyValuePair pair)
	{
		return new KeyValuePair(pair.Key, pair.Value);
	}

	public async Task Logon()
	{
		WsFederationMessage wsFederationMessage = null;

		if (HttpMethods.IsPost(base.Request.Method))
		{
			var parameters = (await this.Request.ReadFormAsync()).Select(Convert);
			wsFederationMessage = new WsFederationMessage(parameters);
		}

		if (wsFederationMessage == null || !wsFederationMessage.IsSignInMessage)
		{
			var signInMessage           = new WsFederationMessage();
            
			signInMessage.IssuerAddress = "https://issuer.address/tenant/fs/ls";
			signInMessage.Wtrealm       = "https://realm.address/tenant/account/logon";

			var redirectUri = signInMessage.CreateSignInUrl();

			return Redirect(redirectUri);
		}
		else
		{
			string token = wsFederationMessage.GetToken();

			SecurityToken validatedToken;

			var tokenHandler = 
				new SamlSecurityTokenHandler()
				{
					MaximumTokenSizeInBytes = Int32.MaxValue                        
				};
			var tokenValidationParameters = new TokenValidationParameters()
			{
				AudienceValidator = (audiences, token, parameters) =>
				{
					return true;
				},
				IssuerValidator = (issuer, token, parameters) =>
				{
					return issuer;
				},
				IssuerSigningKeyValidator = (key, token, parameters) =>
				{
					if ( key is X509SecurityKey )
					{
						X509SecurityKey x509Key = (X509SecurityKey)key;

						// validate cert thumb
						// return x509Key.Certificate.Thumbprint == "alamakota";
						return true;
					}
					else
					{
						return false;
					}
				},   
				IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
				{
					var samlToken   = (SamlSecurityToken)securityToken;
					var signature   = samlToken.Assertion.Signature;
                    
					// rewrite this to handle edge cases!
					var certificate = signature.KeyInfo.X509Data.FirstOrDefault().Certificates.FirstOrDefault();

					var x509Certificate2 = new X509Certificate2(System.Convert.FromBase64String(certificate));

					return new List()
					{
						new X509SecurityKey(x509Certificate2)
					};
				}
			};

			// if this succeeds - we have the principal
			var validatedPrincipal = tokenHandler.ValidateToken(token, tokenValidationParameters, out validatedToken);

			// we strip all claims except the user name to have a complete control over how the cookie is issued
			List claims = new List
				{
					new Claim(ClaimTypes.Name, validatedPrincipal.Identity.Name)
				};

			// create identity
			var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
			ClaimsPrincipal principal = new ClaimsPrincipal(identity);

			await this.HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);

			return Redirect("/");
		}
	}
}
Note how the IssuerSigningKeyValidator delegate replaces the IssuerNameRegistry from the .NET Framework example (linked blog entry). Also note, that other configuration options of the TokenValidationParameters can be changed freely here on a per-request basis.

1 comment:

Gagan Dev said...

This blog made my life very easy. Thanks for sharing.
My issuer app was in MVC5 and Wtrealm is in Dotnet core 6.0 and i am very new to dotnet core.

Here is still one point where i am stuck i.e. i am not able to pass parameters in WsFederationMessage(parameters).

Below are line of codes that are giving compile time error;

Code:
var parameters = (await this.Request.ReadFormAsync()).Select(Convert);
Error:
The type arguments for method 'Enumerable.Select(IEnumerable, Func)' cannot be inferred from the usage. Try specifying the type arguments explicitly.

Code:
private KeyValuePair Convert(KeyValuePair pair)
{
return new KeyValuePair(pair.Key, pair.Value);
}
Error:
'KeyValuePair': static types cannot be used as parameters
'KeyValuePair': static types cannot be used as return types
Cannot create an instance of the static class 'KeyValuePair'

only this line of code is not working in core 6 , is there any solution for it?