SAMLのSPをIISでホストする(IDPはSustainsys のスタブIDPを使用)

[English]

Microsoft .NETの webapp でSAMLのSPを作成し、IISでホストする手順。
IDPはSustainsys のスタブIDPを使用し、下図のような構成を作る。

 

使用するライブラリ ITfoxtec.Identity.SAML2  (https://saml2.sustainsys.com/en/v2/#)
動作環境 Windows 11
Idp Sustainsys SAML IDP  (https://stubidp.sustainsys.com/)

 

参考URL 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. Windows に .NET SDKをインストールする。

以下のURLから、。NET SDKと .NET Core Runtime をダウンロードしてインストールする(バージョンを合わせること)
https://dotnet.microsoft.com/ja-jp/download

 

■2.  webappを作成する

(2.1)以下のコマンドを実行して、Webappを作成する。

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)以下のソースコードを編集•追加する。(それ以外はデフォルトのまま)

(2.2.1) sample/appsettings.json

(IdpMetadataに、SustainsysのStub IDPを指定している)

{
  "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();     // HTTPSを強制したくない場合は、この行をコメントアウト
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. ビルドとデプロイ用のリリース物件を作成

(3.1)以下のコマンドを実行し、ビルドとIISにデプロイする物件を作成する。

dotnet  build
dotnet publish --configuration Release

上記を実行すると、sample¥bin¥Release¥netX.0¥publish 配下にデプロイファイル群一式ができる。

 

■4. IISにアプリケーションとして追加

(4.1)IISマネージャ起動
(4.2)アプリケーションプールを新規作成(デフォルト設定でOK)
(4.3)Default Web Siteを右クリックして、「アプリケーションの追加」を選択
(4.4)上記■3. で作成したアプリケーションフォルダを選択

 

■5. 動かし方

エイリアスを、”sapmple”とした場合、WEBブラウザで以下のようにアクセス。
(5.1)https://localhost/sample/
(5.2)下図のようにIDPで認証する。

 

 

■6. その他

(6.1) メタデータ取得に失敗する場合

もしこのSPを会社のイントラネット配下で動かした場合に、下図ように、Proxyサーバを経由したmetadataの取得に失敗するかもしれない。

その場合の回避策の1つとして、以下からメタデータをダウンロードし、
https://stubidp.sustainsys.com/Metadata
それを、今回作成したアプリケーションの、wwwroot配下に、metadata.xmlとして保存し、appilcation.jsonを以下のように変更することで回避することが可能。

{
  "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) 署名のチェック

今回の例では、署名のチェックをしていない。署名のチェックをする場合は、以下のようにCertificateValidationMode を変更する。ただし、その場合はIDPの公開鍵を信頼済みルートCAとして登録する必要がある。(studbidpの公開鍵を信頼済みルートCAに登録する場合は、開発環境にとどめるなど注意が必要。本番環境でやってしまうと、不特定多数の人間が任意のユーザになりすますことができてしまう)

{
  "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"
 }
}