I'm trying to build an ASPNet SAML2 orientated WebApp and i'm facing issues. I'm using the ITFoxtec library to handle all the process but i'm struggling when a user is trying to reconnect on the webapp. The scenario that causes the issue is the following:
- Launch WebApp on a linux serveur on port 49500
- Connect to the WebApp thanks to IE on a Windows session through the route "Auth/Login"
- Token is generated and accessible
- Try to regenerate once more the token through the route "Auth/Login"
And at this point, the WebApp trigger the following exception:
System.InvalidOperationException: There already exist an Authenticated user. at ITfoxtec.Identity.Saml2.MvcCore.Saml2ResponseExtensions.CreateSession(Saml2AuthnResponse saml2AuthnResponse, HttpContext httpContext, Nullable`1 lifetime, Boolean isPersistent, Func`2 claimsTransform) in C:\XXXXX\ITfoxtec.Identity.Saml2-0c0cef05050d633f000be9399dc6a5b1a009a6da\src\ITfoxtec.Identity.Saml2.MvcCore\Extensions\Saml2ResponseExtensions.cs:line 24
After digging a little bit inside the ITFoxted code, the error is triggered by the HttpContext.User.Identity.IsAuthenticated being set to True when it's run on Linux. I've also seen that on the first iteration, HttpContext.User.Identity.AuthenticationType is set to null and on the second iteration it is set to Federation.
I have done multiple test on multiple environnment and i've end up with the following notes:
- Running the WebApp on Windows with a local pfx works (even with multiple refresh)
- Running the WebApp on Windows the AuthenticationType is never set to Federation
Listed below are the code for launching the WebApp and the AuthController.
[AllowAnonymous]
[EnableCors("SAML_CORS")]
[Route("Auth")]
public class SamlController : Controller
{
const string relayStateReturnUrl = "ReturnUrl";
const string ClaimMatricule = "matricule";
const string WebappsJwtRequest = "getJwt=true";
const string WebappsJwtFailure = "jwt=FAILED";
private readonly Saml2Configuration config;
public IConfiguration Configuration { get; private set; }
public SamlController(IConfiguration configuration, IOptions<Saml2Configuration> configAccessor)
{
this.Configuration = configuration;
config = configAccessor.Value;
}
[Route("Metadata")]
public ObjectResult Metadata()
{
X509Certificate2Collection certificates = new X509Certificate2Collection();
certificates.Import(Configuration["Saml2:SigningCertificateFile"], null, X509KeyStorageFlags.PersistKeySet);
var cert = certificates[0];
string publicSigingKey = Convert.ToBase64String(cert.RawData);
string metadataTemplate = System.IO.File.ReadAllText(Configuration["Jwt:SpMetadataFilePath"]);
metadataTemplate = metadataTemplate.Replace("PUBLIC_RSA_SHA1_SIGNING_CERTIFICATE", publicSigingKey);
metadataTemplate = metadataTemplate.Replace("USERNAME_ATTRIBUTE", Configuration["Saml2:ClaimIdentifierName"]);
string fullHostName = Dns.GetHostEntry("").HostName.ToLower();
if (!Configuration.GetSection("CustomIssuer").GetChildren().Any(item => item.Key == fullHostName))
{
fullHostName = "localhost";
}
metadataTemplate = metadataTemplate.Replace("ISSUER_TO_REPLACE", Configuration["CustomIssuer:" + fullHostName]);
string currentUrl = HttpContext.Request.Host.Value;
metadataTemplate = metadataTemplate.Replace("SAML_ASSERTION_CONSUMPTION_ENDPOINT", $"https://{currentUrl}/Auth/AssertionConsumerService");
return new ObjectResult(metadataTemplate)
{
StatusCode = (int)HttpStatusCode.OK
};
}
[Route("Login")]
public object Login(string returnUrl = null)
{
var binding = new Saml2RedirectBinding();
binding.SetRelayStateQuery(new Dictionary<string, string> { { relayStateReturnUrl, returnUrl ?? Url.Content("~/") } });
if (returnUrl != null && returnUrl.Contains(WebappsJwtFailure))
{
return View();
}
if (returnUrl != null && returnUrl.Contains(WebappsJwtRequest))
{
return binding.Bind(new Saml2AuthnRequest(config)
{
ForceAuthn = true,
}).RedirectLocation.ToString();
}
return binding.Bind(new Saml2AuthnRequest(config)
{
ForceAuthn = true,
}).ToActionResult();
}
[Route("AssertionConsumerService")]
public async Task<IActionResult> AssertionConsumerService()
{
var binding = new Saml2PostBinding();
var saml2AuthnResponse = new Saml2AuthnResponse(config);
binding.ReadSamlResponse(Request.ToGenericHttpRequest(), saml2AuthnResponse);
var relayStateQuery = binding.GetRelayStateQuery();
var returnUrl = relayStateQuery.ContainsKey(relayStateReturnUrl) ? relayStateQuery[relayStateReturnUrl] : Url.Content("~/");
if (saml2AuthnResponse.Status != Saml2StatusCodes.Success) {
var failureReason = $"Unsuccessful SAML Response status: {saml2AuthnResponse.Status}";
Console.WriteLine(failureReason);
returnUrl = returnUrl.Contains(WebappsJwtRequest) ? returnUrl.Replace(WebappsJwtRequest, $"jwt=FAILED({failureReason})") : $"{returnUrl}?jwt=FAILED";
return Redirect(returnUrl);
}
binding.Unbind(Request.ToGenericHttpRequest(), saml2AuthnResponse);
var samlContent = await saml2AuthnResponse.CreateSession(HttpContext, claimsTransform: (claimsPrincipal) => ClaimsTransform.Transform(claimsPrincipal));
var claims = samlContent.Claims;
var matricule = claims.Where(c => c.Type == Configuration["Saml2:ClaimIdentifierName"]).First().Value;
var jwt = this.GenerateJWT(matricule);
if (returnUrl.Contains(WebappsJwtRequest))
{
// If this endpoint is used as an API by the proxy SAML web app, then we need to send the JWT token to it
returnUrl = returnUrl.Replace(WebappsJwtRequest, "jwt=" + jwt);
}
return Redirect(returnUrl);
}
[HttpPost("Logout")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout()
{
if (!User.Identity.IsAuthenticated)
{
return Redirect(Url.Content("~/"));
}
var binding = new Saml2PostBinding();
var saml2LogoutRequest = await new Saml2LogoutRequest(config, User).DeleteSession(HttpContext);
return Redirect("~/");
}
private string GenerateJWT(String userMatricule)
{
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:Key"]));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha512);
string fullHostName = Dns.GetHostEntry("").HostName.ToLower();
if (!Configuration.GetSection("CustomIssuer").GetChildren().Any(item => item.Key == fullHostName))
{
fullHostName = "localhost";
}
var token = new JwtSecurityToken(
Configuration["CustomIssuer:" + fullHostName],
Configuration["CustomIssuer:" + fullHostName],
new Claim[] { new Claim(ClaimMatricule, userMatricule) },
expires: DateTime.Now.AddMinutes(Double.Parse(Configuration["Jwt:ValidityDurationInMinutes"])),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
Below, the launcher:
public class Program
{
public static void Main(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
// custom config file
.AddJsonFile(IdentifyJsonConf(), optional: false, reloadOnChange: false)
.Build();
CreateWebHostBuilder(args, configuration).Build().Run();
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args, IConfiguration conf)
{
return WebHost.CreateDefaultBuilder(args)
.UseWebRoot("wwwroot_saml")
.UseKestrel(options =>
{
options.ListenAnyIP(int.Parse(conf["Port"]), listenOptions =>
{
listenOptions.UseHttps(conf["Saml2:SigningCertificateFile"]);
});
})
.UseConfiguration(conf)
.UseStartup<Startup>();
}
private static string IdentifyJsonConf()
{
string currentDir = Directory.GetCurrentDirectory();
// On récupère le chemin du fichier de config (Linbux par defaut)
var confPath = @"xxxxxx";
var confFile = "xxxx.json";
var path = System.Reflection.Assembly.GetExecutingAssembly().Location;
var dir = Path.GetDirectoryName(path);
var configFileLinux = new FileInfo(Path.Combine(dir, confFile));
if (!configFileLinux.Exists)
{
configFileLinux = new FileInfo(Path.Combine(confPath, confFile));
}
// On recupere l'OS afin de determiner quel fichier de config utiliser
OperatingSystem os = Environment.OSVersion;
PlatformID pid = os.Platform;
switch (pid)
{
// Si on est sur du Windows, alors c'est pour du test
case PlatformID.Win32NT:
case PlatformID.Win32S:
case PlatformID.Win32Windows:
case PlatformID.WinCE:
Console.WriteLine("Getting xxxx.json config file because running on Windows!");
confFile = "xxxxx.json";
var configFileWindows = new FileInfo(Path.Combine(Environment.CurrentDirectory, confFile));
if (!configFileWindows.Exists)
{
configFileWindows = new FileInfo(Path.Combine(dir, confFile));
}
configFileLinux = configFileWindows;
break;
// Si on est sur du Linux
case PlatformID.Unix:
break;
default:
break;
}
return configFileLinux.Name;
}
}
Thanks for the help!