|
| 1 | +using System.Security.Claims; |
| 2 | +using System.Security.Cryptography.X509Certificates; |
| 3 | +using Microsoft.AspNetCore.Authentication; |
| 4 | +using Microsoft.AspNetCore.Authentication.Cookies; |
| 5 | +using Sustainsys.Saml2; |
| 6 | +using Sustainsys.Saml2.AspNetCore2; |
| 7 | +using Sustainsys.Saml2.Configuration; |
| 8 | +using Sustainsys.Saml2.Metadata; |
| 9 | +using Sustainsys.Saml2.WebSso; |
| 10 | +using DotNetEnv; |
| 11 | + |
| 12 | +// @snippet:step1:start |
| 13 | +// @description Load environment variables from .env (searches parent dirs so `dotnet run --project src` finds the .env at the sample root). Existing env vars win. |
| 14 | +Env.TraversePath().NoClobber().Load(); |
| 15 | +// @snippet:step1:end |
| 16 | + |
| 17 | +var builder = WebApplication.CreateBuilder(args); |
| 18 | + |
| 19 | +// @snippet:step2:start |
| 20 | +// @description Configure cookie auth + SAML2 SP against your SecureAuth workspace. |
| 21 | +// AllowUnsolicitedAuthnResponse enables IdP-initiated SSO alongside SP-initiated. |
| 22 | +// The IdP signing certificate is loaded from disk via the path in SAML_IDP_SIGNING_CERT_PATH. |
| 23 | +builder.Services.AddAuthentication(options => |
| 24 | + { |
| 25 | + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; |
| 26 | + options.DefaultChallengeScheme = Saml2Defaults.Scheme; |
| 27 | + }) |
| 28 | + .AddCookie() |
| 29 | + .AddSaml2(options => |
| 30 | + { |
| 31 | + options.SPOptions.EntityId = new EntityId(RequireEnv("SAML_SP_ENTITY_ID")); |
| 32 | + options.SPOptions.ReturnUrl = new Uri("https://localhost:4262/"); |
| 33 | + |
| 34 | + var idp = new IdentityProvider(new EntityId(RequireEnv("SAML_IDP_ENTITY_ID")), options.SPOptions) |
| 35 | + { |
| 36 | + SingleSignOnServiceUrl = new Uri(RequireEnv("SAML_IDP_SSO_URL")), |
| 37 | + Binding = Saml2BindingType.HttpRedirect, |
| 38 | + AllowUnsolicitedAuthnResponse = true, |
| 39 | + }; |
| 40 | + // Signing key must be added BEFORE LoadMetadata = false — the setter triggers |
| 41 | + // IdentityProvider.Validate() which requires SigningKeys to be non-empty. |
| 42 | + idp.SigningKeys.AddConfiguredKey(LoadIdpCert(RequireEnv("SAML_IDP_SIGNING_CERT_PATH"))); |
| 43 | + idp.LoadMetadata = false; |
| 44 | + options.IdentityProviders.Add(idp); |
| 45 | + }); |
| 46 | + |
| 47 | +builder.Services.AddAuthorization(); |
| 48 | + |
| 49 | +static string RequireEnv(string name) => |
| 50 | + Environment.GetEnvironmentVariable(name) |
| 51 | + ?? throw new InvalidOperationException($"Missing required env var: {name}"); |
| 52 | + |
| 53 | +static X509Certificate2 LoadIdpCert(string path) |
| 54 | +{ |
| 55 | + var resolved = ResolveCertPath(path); |
| 56 | + if (resolved is null) |
| 57 | + { |
| 58 | + throw new FileNotFoundException( |
| 59 | + $"IdP signing cert not found searching for '{path}' from cwd up to filesystem root. " + |
| 60 | + $"Download it from the SecureAuth admin UI (SAML IdP → General → Download certificate) " + |
| 61 | + $"and save it at the sample root as {path}."); |
| 62 | + } |
| 63 | + return X509Certificate2.CreateFromPem(File.ReadAllText(resolved)); |
| 64 | +} |
| 65 | + |
| 66 | +// Walk up from the current directory looking for the cert file (mirrors DotNetEnv's |
| 67 | +// TraversePath behavior for .env). When `dotnet run --project src` runs the app, the |
| 68 | +// working directory is `src/` but the cert lives at the sample root one level up. |
| 69 | +static string? ResolveCertPath(string path) |
| 70 | +{ |
| 71 | + if (Path.IsPathRooted(path) && File.Exists(path)) return path; |
| 72 | + var fileName = Path.GetFileName(path); |
| 73 | + var dir = new DirectoryInfo(Environment.CurrentDirectory); |
| 74 | + while (dir is not null) |
| 75 | + { |
| 76 | + var candidate = Path.Combine(dir.FullName, fileName); |
| 77 | + if (File.Exists(candidate)) return candidate; |
| 78 | + dir = dir.Parent; |
| 79 | + } |
| 80 | + return null; |
| 81 | +} |
| 82 | +// @snippet:step2:end |
| 83 | + |
| 84 | +var app = builder.Build(); |
| 85 | +app.UseAuthentication(); |
| 86 | +app.UseAuthorization(); |
| 87 | + |
| 88 | +// @snippet:step3:start |
| 89 | +// @description Wire routes for sign-in challenge, signed-in display, and local sign-out. |
| 90 | +// Sustainsys.Saml2 mounts the SP endpoints (ACS, metadata, SLO) under /Saml2 automatically, |
| 91 | +// so /login just issues a Saml2 challenge and the library handles the rest. |
| 92 | +app.MapGet("/", (HttpContext ctx) => |
| 93 | + ctx.User.Identity?.IsAuthenticated == true |
| 94 | + ? Results.Content(Views.RenderSignedInPage(ctx.User), "text/html") |
| 95 | + : Results.Content(Views.RenderSignedOutPage(), "text/html")); |
| 96 | + |
| 97 | +app.MapGet("/login", () => Results.Challenge( |
| 98 | + new AuthenticationProperties { RedirectUri = "/" }, |
| 99 | + [Saml2Defaults.Scheme])); |
| 100 | + |
| 101 | +// CIAM does not implement SAML SLO. Logout clears the local cookie and redirects home. |
| 102 | +app.MapGet("/logout", () => Results.SignOut( |
| 103 | + new AuthenticationProperties { RedirectUri = "/" }, |
| 104 | + [CookieAuthenticationDefaults.AuthenticationScheme])); |
| 105 | +// @snippet:step3:end |
| 106 | + |
| 107 | +app.Run("https://localhost:4262"); |
| 108 | + |
| 109 | +public partial class Program { } |
| 110 | + |
| 111 | +static class Views |
| 112 | +{ |
| 113 | + public static string RenderSignedOutPage() => """ |
| 114 | + <!doctype html> |
| 115 | + <html><head><title>SecureAuth .NET SAML Demo</title></head> |
| 116 | + <body> |
| 117 | + <h1>SecureAuth .NET SAML Demo</h1> |
| 118 | + <p><a href="/login">Sign in</a></p> |
| 119 | + </body></html> |
| 120 | + """; |
| 121 | + |
| 122 | + public static string RenderSignedInPage(ClaimsPrincipal user) |
| 123 | + { |
| 124 | + var nameId = Escape(user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user.Identity?.Name ?? "there"); |
| 125 | + return $""" |
| 126 | + <!doctype html> |
| 127 | + <html><head><title>SecureAuth .NET SAML Demo</title></head> |
| 128 | + <body> |
| 129 | + <h1>SecureAuth .NET SAML Demo</h1> |
| 130 | + <p>Welcome, {nameId}</p> |
| 131 | + <p><a href="/logout">Sign out</a></p> |
| 132 | + </body></html> |
| 133 | + """; |
| 134 | + } |
| 135 | + |
| 136 | + static string Escape(string s) => s |
| 137 | + .Replace("&", "&") |
| 138 | + .Replace("<", "<") |
| 139 | + .Replace(">", ">") |
| 140 | + .Replace("\"", """) |
| 141 | + .Replace("'", "'"); |
| 142 | +} |
0 commit comments