Skip to content

Commit d886871

Browse files
authored
Merge pull request #48 from SecureAuthCorp/feature/saml-server-samples
feat: SAML SP login samples (Node, Java, .NET)
2 parents c852798 + 03227dc commit d886871

35 files changed

Lines changed: 5666 additions & 55 deletions

samples/dotnet/manifest.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,27 @@ scenarios:
5353
value: "{{SCOPES}}"
5454
- label: "Grant"
5555
value: "Authorization Code + PKCE (+ Refresh Token)"
56+
57+
server_saml_sp_login:
58+
app_type: saml
59+
display_name: "SAML Login"
60+
grant: "SAML 2.0"
61+
description: "SAML SSO with SecureAuth as the IdP. The same ACS endpoint accepts SP-initiated and IdP-initiated responses."
62+
callout: "AuthnRequests are unsigned for simplicity — CIAM accepts unsigned requests by default. For production, configure SP request signing per your library's docs."
63+
run_command: "dotnet restore && dotnet run --project src"
64+
lib: "Sustainsys.Saml2.AspNetCore2"
65+
docs_url: "https://github.com/Sustainsys/Saml2"
66+
config_rows:
67+
- label: "SP Entity ID"
68+
value: "{{SAML_SP_ENTITY_ID}}"
69+
- label: "SP ACS URL"
70+
value: "{{SAML_SP_ACS_URL}}"
71+
- label: "SAML IdP Entity ID"
72+
value: "{{SAML_IDP_ENTITY_ID}}"
73+
- label: "SAML IdP Single Sign-On URL"
74+
value: "{{SAML_IDP_SSO_URL}}"
75+
- label: "SAML IdP Signing Certificate"
76+
value: "Download from admin UI → save as ./saml-certificate.pem"
77+
- label: "IdP-Initiated SSO URL"
78+
value: "{{SAML_IDP_INITIATED_SSO_URL}}"
79+
display_only: true
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# SP (this app) — must match the values you register in CIAM's SAML SP form
2+
SAML_SP_ENTITY_ID=https://localhost:4262/saml/metadata
3+
SAML_SP_ACS_URL=https://localhost:4262/Saml2/Acs
4+
5+
# IdP (SecureAuth) — copy from admin UI → SAML IdP → General tab
6+
SAML_IDP_ENTITY_ID=https://your-tenant.us.connect.secureauth.com/your-workspace/saml/metadata
7+
SAML_IDP_SSO_URL=https://your-tenant.us.connect.secureauth.com/your-workspace/saml/sso
8+
9+
# Local — must match where you saved the downloaded IdP signing certificate
10+
SAML_IDP_SIGNING_CERT_PATH=./saml-certificate.pem
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# .NET — SAML SP Login
2+
3+
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`).
4+
5+
## Prerequisites
6+
7+
- .NET 10 SDK installed (`dotnet --version` → 10.x)
8+
- `dotnet dev-certs https --trust` — run once per machine to trust the local dev HTTPS cert
9+
- A SecureAuth workspace with a SAML Service Provider application registered. In CIAM's admin UI:
10+
1. Create a new SAML SP application.
11+
2. Open the application's **SAML tab** and switch the metadata mode to **Manual** (the default is URL/XML upload).
12+
3. Set **Entity ID** to `https://localhost:4262/saml/metadata` (matches `.env`'s `SAML_SP_ENTITY_ID`).
13+
4. Set **ACS URL** to `https://localhost:4262/Saml2/Acs` (matches `.env`'s `SAML_SP_ACS_URL`).
14+
5. Leave **SP Signing Certificate** empty — this sample uses unsigned AuthnRequests.
15+
16+
## Setup
17+
18+
1. Copy `.env.example` to `.env` and fill in the IdP values from CIAM's admin UI → **SAML IdP → General** tab:
19+
- `SAML_IDP_ENTITY_ID` — copy "SAML IdP Entity ID"
20+
- `SAML_IDP_SSO_URL` — copy "SAML IdP Single Sign-On URL"
21+
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).
22+
3. `dotnet restore`
23+
4. `dotnet run --project src`
24+
5. Open https://localhost:4262
25+
26+
## Try it
27+
28+
**SP-initiated SSO:**
29+
30+
1. Visit https://localhost:4262
31+
2. Click **Sign in** — you'll be redirected to SecureAuth, authenticate, then return to the app authenticated.
32+
33+
**IdP-initiated SSO:**
34+
35+
1. In CIAM's admin UI, copy the "SAML IdP-Initiated SSO URL" (looks like `…/saml/initiate?service_provider_id=…`).
36+
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.
37+
38+
## Next: release additional user attributes
39+
40+
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:
41+
42+
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`).
43+
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.
44+
45+
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.
46+
47+
## What this demonstrates
48+
49+
- Configuring `Sustainsys.Saml2.AspNetCore2` with SecureAuth's SAML IdP (entity ID, SSO URL, signing cert)
50+
- 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`)
51+
- Session carried by an encrypted auth cookie (protected by ASP.NET Core data protection)
52+
- Local logout (clears the cookie — CIAM does not implement SAML SLO)
53+
54+
## Tests
55+
56+
```
57+
dotnet test
58+
```
59+
60+
Four tests cover `/` unauthenticated, `/` authenticated (via fake auth scheme), `/login` SAML challenge, and `/logout` redirect.
61+
62+
## Production notes
63+
64+
- Replace the dev cert with a real TLS certificate (via a reverse proxy or configured Kestrel endpoint).
65+
- 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.
66+
- 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.
67+
- 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.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
Microsoft Visual Studio Solution File, Format Version 12.00
2+
# Visual Studio Version 17
3+
VisualStudioVersion = 17.0.31903.59
4+
MinimumVisualStudioVersion = 10.0.40219.1
5+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "saml-sp-login", "src\saml-sp-login.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
6+
EndProject
7+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "saml-sp-login.tests", "tests\saml-sp-login.tests.csproj", "{B1C2D3E4-F5A6-7890-BCDE-F12345678901}"
8+
EndProject
9+
Global
10+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
11+
Debug|Any CPU = Debug|Any CPU
12+
Release|Any CPU = Release|Any CPU
13+
EndGlobalSection
14+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
15+
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
16+
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
17+
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
18+
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
19+
{B1C2D3E4-F5A6-7890-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
20+
{B1C2D3E4-F5A6-7890-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
21+
{B1C2D3E4-F5A6-7890-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
22+
{B1C2D3E4-F5A6-7890-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
23+
EndGlobalSection
24+
GlobalSection(SolutionProperties) = preSolution
25+
HideSolutionNode = FALSE
26+
EndGlobalSection
27+
GlobalSection(ExtensibilityGlobals) = postSolution
28+
SolutionGuid = {C1D2E3F4-A5B6-7890-CDEF-123456789012}
29+
EndGlobalSection
30+
EndGlobal
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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("&", "&amp;")
138+
.Replace("<", "&lt;")
139+
.Replace(">", "&gt;")
140+
.Replace("\"", "&quot;")
141+
.Replace("'", "&#39;");
142+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
<PropertyGroup>
3+
<TargetFramework>net10.0</TargetFramework>
4+
<Nullable>enable</Nullable>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<AssemblyName>saml-sp-login</AssemblyName>
7+
<RootNamespace>SamlSpLogin</RootNamespace>
8+
</PropertyGroup>
9+
<ItemGroup>
10+
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.11.0" />
11+
<PackageReference Include="DotNetEnv" Version="3.1.1" />
12+
<!-- Override transitive dep with vulnerable Newtonsoft.Json 10.0.1 (NU1903 / GHSA-5crp-9r3c-p9vr). -->
13+
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
14+
</ItemGroup>
15+
</Project>

0 commit comments

Comments
 (0)