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.

No comments: