[C#]SAMLサンプル（.NET6）

SAMLの認証プログラムのサンプルを作る機会があったので手順を記述します。
# 環境
- __Idp__ Okta
- __WSL2__ Ubuntu22.04.01
- __Editor__ VSCode
- __Docker Image__ mcr.microsoft.com/dotnet/sdk

https://hub.docker.com/_/microsoft-dotnet-sdk/

- __.NET__ .NET 6 C#
- __NuGet Package__
    - ITfoxtec.Identity.Saml2 v4.8.2
    - ITfoxtec.Identity.Saml2.Mvc v4.8.2

https://www.itfoxtec.com/IdentitySaml2

# 参考サイト
https://developer.okta.com/blog/2020/10/23/how-to-authenticate-with-saml-in-aspnet-core-and-csharp

# 環境構築
WSLに.NET6をインストールし、ASP.NETのプロジェクトを作成します。
```bash
$ sudo apt update && sudo apt install dotnet6
$ dotnet new webapp -o saml-example
```
saml-exampleディレクトリに入りappsettings.Development.jsonに以下の行を追加してローカルで起動するポートを固定します。
```diff_json:appsettings.Development.json
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
-   }
+   },
+   "Urls": "http://*:5000;https://*:5001"
```
Dockerの設定ファイルです。Dockerfileの中でNuGetからパッケージをインストールと証明書の作成をしています。
```yaml:docker-compose.yml
version: '3.4'

services:
  webapp:
    image: samle-example-image
    build: .
    container_name: samle-example
    ports:
      - 5000:5000
      - 5001:5001
    volumes:
      - ./saml-example:/opt/saml-example
```
```dockerfile:Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:6.0

# HTTPS通信のための証明書を作成しています。
RUN mkdir /https
RUN dotnet dev-certs https -ep /https/aspnetapp.pfx -p password

ENV ASPNETCORE_Kestrel__Certificates__Default__Password=password
ENV ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx

COPY saml-example /opt/saml-example
WORKDIR /opt/saml-example
# NuGetパッケージをインストールしています。
RUN dotnet add package ITfoxtec.Identity.Saml2 --version 4.8.2
RUN dotnet add package ITfoxtec.Identity.Saml2.MvcCore --version 4.8.2

CMD [ "dotnet", "watch", "run" ]
```
今の構成
```
docker-compose.yml
Dockerfile
saml-example
  |- appsettings.Development.json
  |- 
  ...
```
ここまででASP.NETの初期画面が起動することを確認します。`docker-compose up -d`を実行し、  https://localhost:5001 にアクセスして確認してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29324/49511b31-5eda-b436-b698-b7d81a752934.png)

# プログラム実装

<details><summary>プログラムが長いので折りたたみにしました</summary>

```diff_c_sharp: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();

...

app.UseRouting();
+ app.MapDefaultControllerRoute();
+ app.UseSaml2();
```
```diff_c_sharp: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 saml_example.Identity;
+ using Microsoft.Extensions.Options;
+ using System.Security.Authentication;
+ 
+ namespace saml_example.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("~/");
+         }
+     }
+ }
```
```diff_c_sharp:ClaimsTransform.cs
+ using ITfoxtec.Identity.Saml2.Claims;
+ using System.Collections.Generic;
+ using System.Linq;
+ using System.Security.Claims;
+ 
+ namespace saml_example.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;
+         }
+     }
+ }
```
```diff_http:_Layout.cshtml
- <li class="nav-item">
-     <a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
- </li>
- <li class="nav-item">
-     <a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>
- </li>
+ @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>
+ }
```
```diff_html: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>
```
```diff_c_sharp: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 saml_example.Pages
+ {
+     [Authorize]
+     public class ClaimsModel : PageModel
+     {
+         private readonly ILogger<ClaimsModel> _logger;
+ 
+         public ClaimsModel(ILogger<ClaimsModel> logger)
+         {
+             _logger = logger;
+         }
+ 
+         public void OnGet()
+         {
+ 
+         }
+     }
+ }
```

</details>

## Oktaアプリ作成
Applications > Create App Integration > SAML 2.0
App Name: 任意の値（ex. SAML_SAMPLE）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29324/5efd3dba-d6a2-0054-3eef-d12a2cf11562.png)
Single sign on URL: https://localhost:5001/Auth/AssertionConsumerService
Audience URI(SP Entity ID): 任意の値（ex. SAML_SAMPLE）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29324/f509ad5a-c50f-a7e7-4af9-00032827ba64.png)
アプリ作成後、Sign OnタブのSAML Signing CertificatesのStatusがActive > Actions > View IdP metadataのリンクをコピーしておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29324/fe65fe74-d8da-5584-47a7-464565df12a6.png)

## SAML設定
IdPMetadata: メタデータのURL
Issuer: Audience URI(SP Entity ID)と同じ値
CertificateValidationMode: None
```diff_json:appsettings.json
+  "AllowedHosts": "*",
+  "Saml2": {
+      "IdPMetadata": "https://trial-2479249.okta.com/app/exk3fk23t0ToAVGwU697/sso/saml/metadata",
+      "Issuer": "SAML_Example",
+      "SignatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
+      "CertificateValidationMode": "None",
+      "RevocationMode": "NoCheck"
+  }
```
# 動作確認
Loginボタンをクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29324/aca15ced-a962-2bc4-468f-45acec1a9fb6.png)
Oktaのログイン画面が表示されるのでログインします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29324/34f7cf7f-552c-fea5-7358-8b76e5fe886b.png)
アプリにログインしOktaのユーザー名が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29324/6486253c-37db-c78b-700d-c7af5d73795f.png)

# まとめ

ここまでの構成です。編集したファイルのみ記述してます。

```
docker-compose.yml
Dockerfile
saml-example
  |- appsettings.Development.json
  |- appsettings.json
  |- Program.cs
  |- ClaimsTransform.cs
  |- Pages
    |- Claims.cshtml
    |- Claims.cshtml.cs
    |- Shared
      |- _Layout.cshtml
  |- Controllers
    |- AuthController.cs
  ...
```

この実装はSP-Initiatedです。以下のようなフローとなります。今回使用したパッケージはIdp-Initiatedにも対応しているようです。
```mermaid
sequenceDiagram
  actor U as User
  participant A as WebApp
  participant I as Idp
  U->>+A: 1. Loginボタン押下
  A->>+I: 2. リダイレクト
  deactivate A
  I-->>U: 3. Idpのログイン画面
  U-->>I: 4. ログイン情報入力
  I-->>-U: 5. SAMLアサーション
  U-->>+A: 6. リダイレクト
  A-->>-U: 7. ログイン成功
```

# 個人の感想

日本製のSaaSだとSAML機能が付いているサービスがそもそも少なく、対応していたとしても追加料金で10,000/月以上、あるいはもっと、掛かる場合があってちょっとぼったくりかなぁと思ったりしました。
海外製はSAMLに対応しているサービスが多く、追加料金が掛からないイメージです。この辺に海外との差を感じました。SAMLくらいは標準プランで対応すべきです→日本企業。