1

This question is related to IdP Initiated Login with Sustainsys.SAML2 - AuthenticateResult Has No Information.

What I have found is that, with SP-initiated login, the IdP posts to the Saml2/Acs endpoint, which then redirects to the callback method, in my case, "SamlLoginCallback". This method checks to see if the SAML authentication was successful and, if so, it writes a cookie to the user's web browser. This cookie allows the user to access a secondary method called GetLoginDtoSaml, which is secured. This all works perfectly for the SP-initiated workflow.

However, with the IdP-initiated workflow, the call to GetLoginDtoSaml fails for lack of authorization. I don't understand why the same exact code works in the first scenario, but not the latter.

Note that I do see a cookie named ".AspNetCore.Cookies" in my Chrome browser. But I still get a 302 redirection when trying to go to the GetLoginDtoSaml method. It makes no sense to me.

Here are the code methods:

[AllowAnonymous]
[HttpPost, HttpGet]
[Route("api/Security/SamlLoginCallback")]
public async Task<IActionResult> SamlLoginCallback(string returnUrl)
{
  LogDebugInfo("SamlLoginCallback called with returnUrl of " + returnUrl);
  var authenticateResult = await HttpContext.AuthenticateAsync(ApplicationSamlConstants.External);

  if (!authenticateResult.Succeeded)
  {
    LogSamlFailInfo(authenticateResult);
    return Unauthorized();
  }

  // if we get here, we have successful SAML authentication, and should have a username
  // (to which we need to add the redirect client ID if configured to user redirect)
  var userName = _config.GetValue<bool>("Database:IgnoreRedirect")
    ? authenticateResult.Principal.FindFirst(ClaimTypes.NameIdentifier).Value.ToString()
    : _config.GetValue<string>("Saml:RedirectClientId") + "." + authenticateResult.Principal.FindFirst(ClaimTypes.NameIdentifier).Value.ToString();
  
  // required for SAML logout
  // see https://stackoverflow.com/questions/58961868/not-able-to-signout-using-saml2-from-sustainsys
  var samlLogoutNameIdentifier = authenticateResult.Principal.GetClaimValue(CustomClaimTypes.SAMLLogoutNameIdentifier);
  var samlSessionIndex = authenticateResult.Principal.GetClaimValue(CustomClaimTypes.SAMLSessionIndex);

  // set temporary cookie, which will be replaced when client calls GetLoginDtoSaml
  var claims = new List<Claim>
      {
        new Claim(ClaimTypes.Name, userName),
        new Claim(CustomClaimTypes.SAMLLogoutNameIdentifier, samlLogoutNameIdentifier),
        new Claim(CustomClaimTypes.SAMLSessionIndex, samlSessionIndex),
      };
  ClaimsIdentity userIdentity = new ClaimsIdentity(claims, "login");
  ClaimsPrincipal principal = new ClaimsPrincipal(userIdentity);

  // https://stackoverflow.com/questions/46243697/asp-net-core-persistent-authentication-custom-cookie-authentication
  await Microsoft.AspNetCore.Authentication.AuthenticationHttpContextExtensions
    .SignInAsync(HttpContext, CookieAuthenticationDefaults.AuthenticationScheme, principal, new AuthenticationProperties
    {
      IsPersistent = false,          
    });     
  LogDebugInfo("Temporary cookie set in SamlLoginCallback with CookieAuthenticationDefaults.AuthenticationScheme");     

  if (!string.IsNullOrEmpty(returnUrl))
  {
    LogDebugInfo("Redirecting to " + returnUrl);
    return Redirect(returnUrl);
  }

  return this.Ok();
}

[HttpPost]
[Route("api/Security/GetLoginDtoSaml")]
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
public async Task<IActionResult> GetLoginDtoSaml()
{
  try
  {

    LogDebugInfo("GetLoginDtoSaml called");

    var loginDto = new LoginDto();
    loginDto.Username = User.Identity.Name;
    loginDto.IsSamlAuthenticated = true;
    // pick up claims required for SAML logout
    loginDto.SAMLLogoutNameIdentifier = User.GetClaimValue(CustomClaimTypes.SAMLLogoutNameIdentifier);
    loginDto.SAMLSessionIndex = User.GetClaimValue(CustomClaimTypes.SAMLSessionIndex);

    // Now is when we do a database query to get necessary information for the LoginDTO object
    // If the IdP has authenticated the user, but the user does not actually exist in our database,
    // this method will throw an error, and we need to log the user out so they can  try to login again as a valid user
    var dbValidationSuccess = false;
    var dbValidationErrorInfo = "";
    try
    {
      dbValidationSuccess = _securitySvc.ValidateLogin(loginDto);
      LogDebugInfo("GetLoginDtoSaml - dbValidationSuccess = " + dbValidationSuccess);
    }
    catch (Exception ex)
    {
      dbValidationErrorInfo = ex.Message;
      LogError(ex);
    }

    if (dbValidationSuccess)
    {
      // update cookie with relevant data
      await SetCookie(loginDto);
      return StatusCode(Microsoft.AspNetCore.Http.StatusCodes.Status200OK, loginDto);
    }
    else
    {
      // log user out of the application
      LogDebugInfo("GetLoginDtoSaml - logging user out of application");

      await Microsoft.AspNetCore.Authentication.AuthenticationHttpContextExtensions
      .SignOutAsync(HttpContext, CookieAuthenticationDefaults.AuthenticationScheme);

      throw new DisplayException("Despite SAML Authentication from the Identity Provider, user data was not found in the local database. Please refresh the page to retry. Error: " + dbValidationErrorInfo);
    }

  }
  catch (Exception ex)
  {
    return HandleError(ex);
  }
}

In my startup method:

public void ConfigureServices(IServiceCollection services)
{
  services.AddMemoryCache();

  var usingSAML = Configuration.GetValue<bool>("Authentication:UseSAML");
  var usingJWT = Configuration.GetValue<bool>("Authentication:UseJWT");
  AuthenticationBuilder authBuilder = null;

  if (usingSAML)
  {

    // primary reference for SAML code: https://github.com/hmacat/Saml2WebAPIAndAngularSpaExample
    // found via this SO link: https://stackoverflow.com/questions/55025336/sustainsys-saml2-sample-for-asp-net-core-webapi-without-identity


    // added to address bug with Okta integration 
    // see https://www.developreference.com/article/10349604/Sustainsys+SAML2+Sample+for+ASP.NET+Core+WebAPI+without+Identity
    // and https://stackoverflow.com/questions/63853661/authenticateresult-succeeded-is-false-with-okta-and-sustainsys-saml2/63890322#63890322
    services.AddDataProtection()
      .PersistKeysToFileSystem(new DirectoryInfo("Logs"));

    services.Configure<CookiePolicyOptions>(options =>
    {
      // see https://stackoverflow.com/questions/59742825/httpcontext-signinasync-fails-to-set-cookie-and-return-user-identity-isauthent
      options.ConsentCookie.IsEssential = true;
      options.CheckConsentNeeded = context => false;
      options.MinimumSameSitePolicy = SameSiteMode.None;


      // Some older browsers don't support SameSiteMode.None.
      options.OnAppendCookie = cookieContext => SameSite.CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
      options.OnDeleteCookie = cookieContext => SameSite.CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
    });

    authBuilder = services.AddAuthentication(o =>
    {
      o.DefaultScheme = ApplicationSamlConstants.Application;
      o.DefaultSignInScheme = ApplicationSamlConstants.External;
      o.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
      o.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    });

    authBuilder.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
    {
      // see https://stackoverflow.com/questions/46243697/asp-net-core-persistent-authentication-custom-cookie-authentication
      options.ExpireTimeSpan = new System.TimeSpan(365, 0, 0, 0, 0);
      options.AccessDeniedPath = new PathString("/login");
      options.LoginPath = new PathString("/login");

      // see https://stackoverflow.com/questions/59742825/httpcontext-signinasync-fails-to-set-cookie-and-return-user-identity-isauthent
      options.Cookie.IsEssential = true;
      options.Cookie.HttpOnly = true;
      //options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
      options.Cookie.SameSite = SameSiteMode.None;
    })
    .AddCookie(ApplicationSamlConstants.Application)
    .AddCookie(ApplicationSamlConstants.External)
    .AddSaml2(options =>
    {
      options.SPOptions.EntityId = new EntityId(this.Configuration["Saml:SPEntityId"]);
      var allowIdpInitiated = Configuration.GetValue<bool>("Saml:AllowIdPInitiated"); 
      if (allowIdpInitiated)
      {
        var siteRoot = this.Configuration["Saml:SiteRoot"];
        var siteRootEncoded = WebUtility.UrlEncode(siteRoot + "?idp=y");    // add idp parm so javascript knows what's going on       
        var returnUrl = string.Format("{0}/api/Security/SamlLoginCallback?returnUrl={1}", siteRoot, siteRootEncoded);
        options.SPOptions.ReturnUrl = new System.Uri(returnUrl);
      }   
      options.IdentityProviders.Add(
          new IdentityProvider(
              new EntityId(this.Configuration["Saml:IDPEntityId"]), options.SPOptions)
          {
            MetadataLocation = this.Configuration["Saml:IDPMetaDataBaseUrl"],
            LoadMetadata = true,
            AllowUnsolicitedAuthnResponse = allowIdpInitiated,   
          });
      options.SPOptions.ServiceCertificates.Add(new X509Certificate2(this.Configuration["Saml:CertificateFileName"]));
      
    });
  }

}

JRS
  • 569
  • 9
  • 26
  • Currently working on a similar project. I'm integrating okta saml2 on top of an existing web app. Did you ever get this sorted? I've been struggling with the lack of examples for integrating with Identity for example. Happy to chat outside of here too. – George McKibbin Oct 02 '20 at 04:38
  • @GeorgeMcKibbin. They have a helpful example .net core app that uses .net core identity here: https://github.com/Sustainsys/Saml2/tree/master/Samples/SampleAspNetCore2ApplicationNETFramework. Also, see https://stackoverflow.com/questions/35292397/asp-net-core-saml-authentication, which points to a reference implementation for – JRS Oct 12 '20 at 12:52

1 Answers1

0

The problem here ultimately turned out to be inconsistent case sensitivity in cookie paths used to write then read the cookie. So, while the cookie was being written, it could not be read.

JRS
  • 569
  • 9
  • 26