Hosgint SAML SP on IIS(using Sustainsys stub IDP)
How to create SAML SP and hosting it on IIS.
This article shows how to create SAML SP with Sustainsys’s stub IDP.
Library | ITfoxtec.Identity.SAML2 (https://saml2.sustainsys.com/en/v2/#) |
Environment | Windows 11 |
Idp | Sustainsys SAML IDP (https://stubidp.sustainsys.com/) |
reference | https://qiita.com/urushibata/items/85eb2d0e25b3cd290e14 https://developer.okta.com/blog/2020/10/23/how-to-authenticate-with-saml-in-aspnet-core-and-csharp |
■1. Install .NET SDK
Download .NET SDK and .NET Core Runtime from the link below.(You have to download the same version of .NET)
https://dotnet.microsoft.com/ja-jp/download
■2. Create webapp
(2.1)Create the webapp using the commands below.
dotnet new webapp -o sample cd sample dotnet add package ITfoxtec.Identity.Saml2 --version 4.8.2 dotnet add package ITfoxtec.Identity.Saml2.MvcCore --version 4.8.2
(2.2)Add/Modify source codes below
(2.2.1) sample/appsettings.json
(Set Sustainsys Stub IDP as IdpMetadata)
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "Saml2": { "IdPMetadata": "https://stubidp.sustainsys.com/Metadata", "Issuer": "http://saml_sample/", "SignatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "CertificateValidationMode": "None", "RevocationMode": "NoCheck" } }
(2.2.2) sample/ClaimsTransform.cs
using ITfoxtec.Identity.Saml2.Claims; using System.Collections.Generic; using System.Linq; using System.Security.Claims; namespace sample.Identity { public static class ClaimsTransform { public static ClaimsPrincipal Transform(ClaimsPrincipal incomingPrincipal) { if (!incomingPrincipal.Identity.IsAuthenticated) { return incomingPrincipal; } return CreateClaimsPrincipal(incomingPrincipal); } private static ClaimsPrincipal CreateClaimsPrincipal(ClaimsPrincipal incomingPrincipal) { var claims = new List<Claim>(); // All claims claims.AddRange(incomingPrincipal.Claims); // Or custom claims //claims.AddRange(GetSaml2LogoutClaims(incomingPrincipal)); //claims.Add(new Claim(ClaimTypes.NameIdentifier, GetClaimValue(incomingPrincipal, ClaimTypes.NameIdentifier))); return new ClaimsPrincipal(new ClaimsIdentity(claims, incomingPrincipal.Identity.AuthenticationType, ClaimTypes.NameIdentifier, ClaimTypes.Role) { BootstrapContext = ((ClaimsIdentity)incomingPrincipal.Identity).BootstrapContext }); } private static IEnumerable<Claim> GetSaml2LogoutClaims(ClaimsPrincipal principal) { yield return GetClaim(principal, Saml2ClaimTypes.NameId); yield return GetClaim(principal, Saml2ClaimTypes.NameIdFormat); yield return GetClaim(principal, Saml2ClaimTypes.SessionIndex); } private static Claim GetClaim(ClaimsPrincipal principal, string claimType) { return ((ClaimsIdentity)principal.Identity).Claims.Where(c => c.Type == claimType).FirstOrDefault(); } private static string GetClaimValue(ClaimsPrincipal principal, string claimType) { var claim = GetClaim(principal, claimType); return claim != null ? claim.Value : null; } } }
(2.2.3) sample/Program.cs
using ITfoxtec.Identity.Saml2; using ITfoxtec.Identity.Saml2.Schemas.Metadata; using ITfoxtec.Identity.Saml2.MvcCore.Configuration; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddRazorPages(); builder.Services.Configure<Saml2Configuration>(builder.Configuration.GetSection("Saml2")); builder.Services.Configure<Saml2Configuration>(saml2Configuration => { saml2Configuration.AllowedAudienceUris.Add(saml2Configuration.Issuer); var entityDescriptor = new EntityDescriptor(); entityDescriptor.ReadIdPSsoDescriptorFromUrl(new Uri(builder.Configuration["Saml2:IdPMetadata"])); if (entityDescriptor.IdPSsoDescriptor != null) { saml2Configuration.SingleSignOnDestination = entityDescriptor.IdPSsoDescriptor.SingleSignOnServices.First().Location; saml2Configuration.SignatureValidationCertificates.AddRange(entityDescriptor.IdPSsoDescriptor.SigningCertificates); } else { throw new Exception("IdPSsoDescriptor not loaded from metadata."); } }); builder.Services.AddSaml2(); var app = builder.Build(); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error"); // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } app.UseHttpsRedirection(); // If you don't want to force HTTPS, comment out this line. app.UseStaticFiles(); app.UseRouting(); app.MapDefaultControllerRoute(); app.UseSaml2(); app.UseAuthorization(); app.MapRazorPages(); app.Run();
(2.2.4) sample/Controllers/AuthController.cs
using ITfoxtec.Identity.Saml2; using ITfoxtec.Identity.Saml2.Schemas; using ITfoxtec.Identity.Saml2.MvcCore; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using sample.Identity; using Microsoft.Extensions.Options; using System.Security.Authentication; namespace sample.Controllers { [AllowAnonymous] [Route("Auth")] public class AuthController : Controller { const string relayStateReturnUrl = "ReturnUrl"; private readonly Saml2Configuration config; public AuthController(IOptions<Saml2Configuration> configAccessor) { config = configAccessor.Value; } [Route("Login")] public IActionResult Login(string returnUrl = null) { var binding = new Saml2RedirectBinding(); binding.SetRelayStateQuery(new Dictionary<string, string> { { relayStateReturnUrl, returnUrl ?? Url.Content("~/") } }); return binding.Bind(new Saml2AuthnRequest(config)).ToActionResult(); } [Route("AssertionConsumerService")] public async Task<IActionResult> AssertionConsumerService() { var binding = new Saml2PostBinding(); var saml2AuthnResponse = new Saml2AuthnResponse(config); binding.ReadSamlResponse(Request.ToGenericHttpRequest(), saml2AuthnResponse); if (saml2AuthnResponse.Status != Saml2StatusCodes.Success) { throw new AuthenticationException($"SAML Response status: {saml2AuthnResponse.Status}"); } binding.Unbind(Request.ToGenericHttpRequest(), saml2AuthnResponse); await saml2AuthnResponse.CreateSession(HttpContext, claimsTransform: (claimsPrincipal) => ClaimsTransform.Transform(claimsPrincipal)); var relayStateQuery = binding.GetRelayStateQuery(); var returnUrl = relayStateQuery.ContainsKey(relayStateReturnUrl) ? relayStateQuery[relayStateReturnUrl] : Url.Content("~/"); 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("~/"); } } }
(2.2.5) sample/Pages/Claims.cshtml
@page @model ClaimsModel @{ ViewData["Title"] = "Home page"; } <div class="row"> <div class="col-md-12"> <h2>The users Claims (Iteration on User.Claims)</h2> <p> @foreach (var claim in User.Claims) { <strong>@claim.Type</strong> <br /> <span style="padding-left: 10px">Value: @claim.Value</span> <br /> } </p> </div> </div>
(2.2.6) sample/Pages/Claims.cshtml.cs
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Logging; namespace sample.Pages { [Authorize] public class ClaimsModel : PageModel { private readonly ILogger<ClaimsModel> _logger; public ClaimsModel(ILogger<ClaimsModel> logger) { _logger = logger; } public void OnGet() { } } }
(2.2.7) sample/Pages/Shared/_Layout.cshtml
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>@ViewData["Title"] - saml_example</title> <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" /> <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> </head> <body> <header> <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3"> <div class="container"> <a class="navbar-brand" asp-area="" asp-page="/Index">sample</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="navbar-collapse collapse d-sm-inline-flex justify-content-between"> <ul class="navbar-nav flex-grow-1"> @if (((System.Security.Claims.ClaimsIdentity)User.Identity).IsAuthenticated) { <li class="nav-item"> <a class="nav-link text-dark" asp-area="" asp-page="/Claims">SAML Claims</a> </li> <li> @if (User.Identity.Name != null) { <span class="navbar-text">Hello, @User.Identity.Name!</span> } else { <span class="navbar-text">Hello</span> } </li> <li> <form class="form-inline" asp-controller="Auth" asp-action="Logout"> <button type="submit" class="nav-link btn btn-link text-dark">Logout</button> </form> </li> } else { <li class="nav-item"> <a class="nav-link text-dark" asp-controller="Auth" asp-action="Login">Login</a> </li> } </ul> </div> </div> </nav> </header> <div class="container"> <main role="main" class="pb-3"> @RenderBody() </main> </div> <footer class="border-top footer text-muted"> <div class="container"> © 2025 - saml_example - <a asp-area="" asp-page="/Privacy">Privacy</a> </div> </footer> <script src="~/lib/jquery/dist/jquery.min.js"></script> <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script> <script src="~/js/site.js" asp-append-version="true"></script> @await RenderSectionAsync("Scripts", required: false) </body> </html>
■3. Build and Create publish release
(3.1)Build and crate publish release using commands below.
dotnet build dotnet publish --configuration Release
after commands above. you can get publish files under the path (sample¥bin¥Release¥netX.0¥publish)
■4. Add application on IIS
(4.1)Start IIS Manager
(4.2)Create new application pool(default settings)
(4.3)Right Click on “Default Web Site” and select “Add application”
(4.4)Select the app you made in (3.1)
■5. How to access.
If you made the application alias as “sample”, you can access the app as follows.
(5.1)https://localhost/sample/
(5.2)Authenticate as follows.
■6. Others
(6.1) If you cann’t get the metadata
you might not be able to get the metadat if you run the app under your company’s intranet.
If so, you can download the metadata before and, save it as “metadata.xml”. and then you change your application.json as follow.
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "Saml2": { "IdPMetadata":"https://localhost/sample/metadata.xml", // "https://stubidp.sustainsys.com/Metadata", "Issuer": "http://saml_sample/", "SignatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "CertificateValidationMode": "None", "RevocationMode": "NoCheck" } }
(6.2) Validate Certification
In this article. you don’t checke the IDP’S sign. If you want vaidate the sign. you have to download the certificate and configure your service provider to trust it.
(Warning: Never trust the stub idp certificate in a production environment as it will allow anyone to sign in to your application as whatever user they choose.)
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "Saml2": { "IdPMetadata":"https://localhost/sample/metadata.xml", // "https://stubidp.sustainsys.com/Metadata", "Issuer": "http://saml_sample/", "SignatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "CertificateValidationMode": "ChainTrust", "RevocationMode": "NoCheck" } }