Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions samples/dotnet/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,27 @@ scenarios:
value: "{{SCOPES}}"
- label: "Grant"
value: "Authorization Code + PKCE (+ Refresh Token)"

server_saml_sp_login:
app_type: saml
display_name: "SAML Login"
grant: "SAML 2.0"
description: "SAML SSO with SecureAuth as the IdP. The same ACS endpoint accepts SP-initiated and IdP-initiated responses."
callout: "AuthnRequests are unsigned for simplicity — CIAM accepts unsigned requests by default. For production, configure SP request signing per your library's docs."
run_command: "dotnet restore && dotnet run --project src"
lib: "Sustainsys.Saml2.AspNetCore2"
docs_url: "https://github.com/Sustainsys/Saml2"
config_rows:
- label: "SP Entity ID"
value: "{{SAML_SP_ENTITY_ID}}"
- label: "SP ACS URL"
value: "{{SAML_SP_ACS_URL}}"
- label: "SAML IdP Entity ID"
value: "{{SAML_IDP_ENTITY_ID}}"
- label: "SAML IdP Single Sign-On URL"
value: "{{SAML_IDP_SSO_URL}}"
- label: "SAML IdP Signing Certificate"
value: "Download from admin UI → save as ./saml-certificate.pem"
- label: "IdP-Initiated SSO URL"
value: "{{SAML_IDP_INITIATED_SSO_URL}}"
display_only: true
10 changes: 10 additions & 0 deletions samples/dotnet/saml-sp-login/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# SP (this app) — must match the values you register in CIAM's SAML SP form
SAML_SP_ENTITY_ID=https://localhost:4262/saml/metadata
SAML_SP_ACS_URL=https://localhost:4262/Saml2/Acs

# IdP (SecureAuth) — copy from admin UI → SAML IdP → General tab
SAML_IDP_ENTITY_ID=https://your-tenant.us.connect.secureauth.com/your-workspace/saml/metadata
SAML_IDP_SSO_URL=https://your-tenant.us.connect.secureauth.com/your-workspace/saml/sso

# Local — must match where you saved the downloaded IdP signing certificate
SAML_IDP_SIGNING_CERT_PATH=./saml-certificate.pem
67 changes: 67 additions & 0 deletions samples/dotnet/saml-sp-login/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# .NET — SAML SP Login

Minimal ASP.NET Core 10 Minimal-APIs app demonstrating SAML SSO with SecureAuth as the SAML IdP, using [`Sustainsys.Saml2.AspNetCore2`](https://github.com/Sustainsys/Saml2). Supports both **SP-initiated** and **IdP-initiated** flows through the same ACS endpoint (`AllowUnsolicitedAuthnResponse = true`).

## Prerequisites

- .NET 10 SDK installed (`dotnet --version` → 10.x)
- `dotnet dev-certs https --trust` — run once per machine to trust the local dev HTTPS cert
- A SecureAuth workspace with a SAML Service Provider application registered. In CIAM's admin UI:
1. Create a new SAML SP application.
2. Open the application's **SAML tab** and switch the metadata mode to **Manual** (the default is URL/XML upload).
3. Set **Entity ID** to `https://localhost:4262/saml/metadata` (matches `.env`'s `SAML_SP_ENTITY_ID`).
4. Set **ACS URL** to `https://localhost:4262/Saml2/Acs` (matches `.env`'s `SAML_SP_ACS_URL`).
5. Leave **SP Signing Certificate** empty — this sample uses unsigned AuthnRequests.

## Setup

1. Copy `.env.example` to `.env` and fill in the IdP values from CIAM's admin UI → **SAML IdP → General** tab:
- `SAML_IDP_ENTITY_ID` — copy "SAML IdP Entity ID"
- `SAML_IDP_SSO_URL` — copy "SAML IdP Single Sign-On URL"
2. Download the IdP signing certificate (admin UI → SAML IdP → General → "SAML IdP Signing Certificate" → Download). The browser saves it as `saml-certificate.pem` — drop the file into this sample's directory as-is (no rename needed).
3. `dotnet restore`
4. `dotnet run --project src`
5. Open https://localhost:4262

## Try it

**SP-initiated SSO:**

1. Visit https://localhost:4262
2. Click **Sign in** — you'll be redirected to SecureAuth, authenticate, then return to the app authenticated.

**IdP-initiated SSO:**

1. In CIAM's admin UI, copy the "SAML IdP-Initiated SSO URL" (looks like `…/saml/initiate?service_provider_id=…`).
2. Visit that URL in a new browser session — SecureAuth will POST a SAML response directly to the app's ACS endpoint and you'll land authenticated.

## Next: release additional user attributes

By default the SAML response only carries the user's `nameID`. To get attributes like `given_name`, `family_name`, or `email` in the assertion (accessible via `User.FindFirst("given_name")?.Value` in this sample), configure them in CIAM:

1. **Workspace claims** — make sure `saml_assertion`-typed claims exist for the attributes you want. In the workspace, open **OAuth Server → Claims** and create them if missing (one per attribute, e.g. `given_name`, `family_name`, `email`).
2. **App attribute release** — on the SAML SP application, open the **SAML Attributes** tab and select the claims you want this app to receive. Save.

The next SAML response will include an `<AttributeStatement>` with those attributes; Sustainsys.Saml2 maps them onto the `ClaimsPrincipal` as claims with the attribute names as claim types. Update `Views.RenderSignedInPage` in `src/Program.cs` to read `user.FindFirst("given_name")?.Value` etc. if you want to display them.

## What this demonstrates

- Configuring `Sustainsys.Saml2.AspNetCore2` with SecureAuth's SAML IdP (entity ID, SSO URL, signing cert)
- An ACS endpoint at `/Saml2/Acs` (Sustainsys's default) that validates IdP-signed SAML responses and accepts both SP- and IdP-initiated flows (`AllowUnsolicitedAuthnResponse = true`)
- Session carried by an encrypted auth cookie (protected by ASP.NET Core data protection)
- Local logout (clears the cookie — CIAM does not implement SAML SLO)

## Tests

```
dotnet test
```

Four tests cover `/` unauthenticated, `/` authenticated (via fake auth scheme), `/login` SAML challenge, and `/logout` redirect.

## Production notes

- Replace the dev cert with a real TLS certificate (via a reverse proxy or configured Kestrel endpoint).
- ASP.NET Core's data-protection keys default to in-memory in dev. In production, configure a persistent key ring (filesystem / blob / Redis) so auth cookies survive restarts and work across instances.
- Consider enabling SP request signing for production (configure a `signing` X509 credential on `SPOptions.ServiceCertificates`). SecureAuth accepts unsigned requests by default; this sample skips signing for simplicity.
- The `AllowUnsolicitedAuthnResponse = true` setting accepts ALL unsolicited responses — fine for IdP-initiated flows, but consider adding additional checks (e.g., RelayState validation) if you support both flows from untrusted contexts.
30 changes: 30 additions & 0 deletions samples/dotnet/saml-sp-login/saml-sp-login.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "saml-sp-login", "src\saml-sp-login.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "saml-sp-login.tests", "tests\saml-sp-login.tests.csproj", "{B1C2D3E4-F5A6-7890-BCDE-F12345678901}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
{B1C2D3E4-F5A6-7890-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B1C2D3E4-F5A6-7890-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B1C2D3E4-F5A6-7890-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B1C2D3E4-F5A6-7890-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C1D2E3F4-A5B6-7890-CDEF-123456789012}
EndGlobalSection
EndGlobal
142 changes: 142 additions & 0 deletions samples/dotnet/saml-sp-login/src/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Sustainsys.Saml2;
using Sustainsys.Saml2.AspNetCore2;
using Sustainsys.Saml2.Configuration;
using Sustainsys.Saml2.Metadata;
using Sustainsys.Saml2.WebSso;
using DotNetEnv;

// @snippet:step1:start
// @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.
Env.TraversePath().NoClobber().Load();
// @snippet:step1:end

var builder = WebApplication.CreateBuilder(args);

// @snippet:step2:start
// @description Configure cookie auth + SAML2 SP against your SecureAuth workspace.
// AllowUnsolicitedAuthnResponse enables IdP-initiated SSO alongside SP-initiated.
// The IdP signing certificate is loaded from disk via the path in SAML_IDP_SIGNING_CERT_PATH.
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = Saml2Defaults.Scheme;
})
.AddCookie()
.AddSaml2(options =>
{
options.SPOptions.EntityId = new EntityId(RequireEnv("SAML_SP_ENTITY_ID"));
options.SPOptions.ReturnUrl = new Uri("https://localhost:4262/");

var idp = new IdentityProvider(new EntityId(RequireEnv("SAML_IDP_ENTITY_ID")), options.SPOptions)
{
SingleSignOnServiceUrl = new Uri(RequireEnv("SAML_IDP_SSO_URL")),
Binding = Saml2BindingType.HttpRedirect,
AllowUnsolicitedAuthnResponse = true,
};
// Signing key must be added BEFORE LoadMetadata = false — the setter triggers
// IdentityProvider.Validate() which requires SigningKeys to be non-empty.
idp.SigningKeys.AddConfiguredKey(LoadIdpCert(RequireEnv("SAML_IDP_SIGNING_CERT_PATH")));
idp.LoadMetadata = false;
options.IdentityProviders.Add(idp);
});

builder.Services.AddAuthorization();

static string RequireEnv(string name) =>
Environment.GetEnvironmentVariable(name)
?? throw new InvalidOperationException($"Missing required env var: {name}");

static X509Certificate2 LoadIdpCert(string path)
{
var resolved = ResolveCertPath(path);
if (resolved is null)
{
throw new FileNotFoundException(
$"IdP signing cert not found searching for '{path}' from cwd up to filesystem root. " +
$"Download it from the SecureAuth admin UI (SAML IdP → General → Download certificate) " +
$"and save it at the sample root as {path}.");
}
return X509Certificate2.CreateFromPem(File.ReadAllText(resolved));
}

// Walk up from the current directory looking for the cert file (mirrors DotNetEnv's
// TraversePath behavior for .env). When `dotnet run --project src` runs the app, the
// working directory is `src/` but the cert lives at the sample root one level up.
static string? ResolveCertPath(string path)
{
if (Path.IsPathRooted(path) && File.Exists(path)) return path;
var fileName = Path.GetFileName(path);
var dir = new DirectoryInfo(Environment.CurrentDirectory);
while (dir is not null)
{
var candidate = Path.Combine(dir.FullName, fileName);
if (File.Exists(candidate)) return candidate;
dir = dir.Parent;
}
return null;
}
// @snippet:step2:end

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

// @snippet:step3:start
// @description Wire routes for sign-in challenge, signed-in display, and local sign-out.
// Sustainsys.Saml2 mounts the SP endpoints (ACS, metadata, SLO) under /Saml2 automatically,
// so /login just issues a Saml2 challenge and the library handles the rest.
app.MapGet("/", (HttpContext ctx) =>
ctx.User.Identity?.IsAuthenticated == true
? Results.Content(Views.RenderSignedInPage(ctx.User), "text/html")
: Results.Content(Views.RenderSignedOutPage(), "text/html"));

app.MapGet("/login", () => Results.Challenge(
new AuthenticationProperties { RedirectUri = "/" },
[Saml2Defaults.Scheme]));

// CIAM does not implement SAML SLO. Logout clears the local cookie and redirects home.
app.MapGet("/logout", () => Results.SignOut(
new AuthenticationProperties { RedirectUri = "/" },
[CookieAuthenticationDefaults.AuthenticationScheme]));
// @snippet:step3:end

app.Run("https://localhost:4262");

public partial class Program { }

static class Views
{
public static string RenderSignedOutPage() => """
<!doctype html>
<html><head><title>SecureAuth .NET SAML Demo</title></head>
<body>
<h1>SecureAuth .NET SAML Demo</h1>
<p><a href="/login">Sign in</a></p>
</body></html>
""";

public static string RenderSignedInPage(ClaimsPrincipal user)
{
var nameId = Escape(user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user.Identity?.Name ?? "there");
return $"""
<!doctype html>
<html><head><title>SecureAuth .NET SAML Demo</title></head>
<body>
<h1>SecureAuth .NET SAML Demo</h1>
<p>Welcome, {nameId}</p>
<p><a href="/logout">Sign out</a></p>
</body></html>
""";
}

static string Escape(string s) => s
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
15 changes: 15 additions & 0 deletions samples/dotnet/saml-sp-login/src/saml-sp-login.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>saml-sp-login</AssemblyName>
<RootNamespace>SamlSpLogin</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.11.0" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<!-- Override transitive dep with vulnerable Newtonsoft.Json 10.0.1 (NU1903 / GHSA-5crp-9r3c-p9vr). -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>
Loading