Skip to content

Commit cf7b51d

Browse files
ksroda-saclaude
andcommitted
feat: add .NET server_login_auth_code sample
- Minimal ASP.NET Core Minimal-APIs app targeting net10.0 using Microsoft.AspNetCore.Authentication.OpenIdConnect - Port 4260 (HTTPS via dotnet dev-certs); cookie + OIDC auth schemes; SaveTokens + GetClaimsFromUserInfoEndpoint - Env via DotNetEnv for parity with Node samples / acp Quickstart UX - 4 xUnit + WebApplicationFactory tests covering / unauth, / auth, /login challenge, /logout sign-out (OIDC Configuration pre-populated to avoid discovery calls in tests) - Regenerated snippets.json + snippet-manifest.yaml with new dotnet entry (3 snippet steps, install = manifest.lib, lib_version from .csproj regex) - Bump CI workflow dotnet-version to 10.0.x to match the sample's target framework - Default OIDC SignedOutCallbackPath (/signout-callback-oidc) — overriding to "/" makes the middleware swallow all requests to / Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 020738a commit cf7b51d

12 files changed

Lines changed: 427 additions & 3 deletions

.github/workflows/test-dotnet.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
- uses: actions/checkout@v6
3939
- uses: actions/setup-dotnet@v4
4040
with:
41-
dotnet-version: "8.0.x"
41+
dotnet-version: "10.0.x"
4242
- name: Restore
4343
working-directory: ${{ matrix.project }}
4444
run: dotnet restore
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
CLIENT_ID=your-client-id
2+
CLIENT_SECRET=your-client-secret
3+
ISSUER_URL=https://your-tenant.us.connect.secureauth.com/your-workspace
4+
REDIRECT_URI=https://localhost:4260/callback
5+
POST_LOGOUT_URI=https://localhost:4260/signout-callback-oidc
6+
SCOPES=openid profile email
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# .NET — Server Login with Auth Code + PKCE
2+
3+
Minimal ASP.NET Core 8 Minimal-APIs app demonstrating server-side OIDC login using `Microsoft.AspNetCore.Authentication.OpenIdConnect`. The server holds the client secret and manages the session via an encrypted auth cookie.
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 application configured as a **confidential server app** (client type `server_web`, with a client secret)
10+
11+
## Setup
12+
13+
1. Copy `.env.example` to `.env` and fill in your SecureAuth values (including `CLIENT_SECRET`)
14+
2. `dotnet restore`
15+
3. `dotnet run --project src`
16+
4. Open https://localhost:4260
17+
18+
## What this demonstrates
19+
20+
- OIDC discovery via `AddOpenIdConnect(options => { options.Authority = ... })`
21+
- Authorization Code + PKCE flow (middleware handles code exchange, state validation, PKCE verifier)
22+
- Auth cookie with ID token stored server-side (never reaches the browser as a token)
23+
- RP-initiated logout via `Results.SignOut(...)` on Cookie + OIDC schemes
24+
25+
## Tests
26+
27+
```
28+
dotnet test
29+
```
30+
31+
Covers the four non-library code paths: `/` unauthenticated, `/` authenticated (via fake auth scheme), `/login` challenge, `/logout` sign-out.
32+
33+
## Production notes
34+
35+
- Replace the dev cert with a real TLS certificate (via a reverse proxy or configured Kestrel endpoint).
36+
- 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.
37+
- Consider scoping auth middleware to protected routes only via `[Authorize]` attributes or `RequireAuthorization()`.
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}") = "login-auth-code", "src\login-auth-code.csproj", "{78EB9BB4-218C-40E2-97DF-0A5EB467F957}"
6+
EndProject
7+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "login-auth-code.tests", "tests\login-auth-code.tests.csproj", "{1F9DCFBA-4FB4-4806-A98A-A186DD115179}"
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+
{78EB9BB4-218C-40E2-97DF-0A5EB467F957}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
16+
{78EB9BB4-218C-40E2-97DF-0A5EB467F957}.Debug|Any CPU.Build.0 = Debug|Any CPU
17+
{78EB9BB4-218C-40E2-97DF-0A5EB467F957}.Release|Any CPU.ActiveCfg = Release|Any CPU
18+
{78EB9BB4-218C-40E2-97DF-0A5EB467F957}.Release|Any CPU.Build.0 = Release|Any CPU
19+
{1F9DCFBA-4FB4-4806-A98A-A186DD115179}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
20+
{1F9DCFBA-4FB4-4806-A98A-A186DD115179}.Debug|Any CPU.Build.0 = Debug|Any CPU
21+
{1F9DCFBA-4FB4-4806-A98A-A186DD115179}.Release|Any CPU.ActiveCfg = Release|Any CPU
22+
{1F9DCFBA-4FB4-4806-A98A-A186DD115179}.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 = {C3B3262A-0EC4-405A-9AAF-D2CFB28EB9A6}
29+
EndGlobalSection
30+
EndGlobal
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using System.Security.Claims;
2+
using Microsoft.AspNetCore.Authentication;
3+
using Microsoft.AspNetCore.Authentication.Cookies;
4+
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
5+
using DotNetEnv;
6+
7+
// @snippet:step1:start
8+
// @description Load environment variables from .env so Environment.GetEnvironmentVariable sees them
9+
Env.Load();
10+
// @snippet:step1:end
11+
12+
var builder = WebApplication.CreateBuilder(args);
13+
14+
// @snippet:step2:start
15+
// @description Configure cookie auth + OpenID Connect against your SecureAuth workspace
16+
builder.Services.AddAuthentication(options =>
17+
{
18+
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
19+
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
20+
})
21+
.AddCookie()
22+
.AddOpenIdConnect(options =>
23+
{
24+
options.Authority = Environment.GetEnvironmentVariable("ISSUER_URL");
25+
options.ClientId = Environment.GetEnvironmentVariable("CLIENT_ID");
26+
options.ClientSecret = Environment.GetEnvironmentVariable("CLIENT_SECRET");
27+
options.ResponseType = "code";
28+
options.UsePkce = true;
29+
options.SaveTokens = true;
30+
options.GetClaimsFromUserInfoEndpoint = true;
31+
options.CallbackPath = "/callback";
32+
options.Scope.Clear();
33+
foreach (var scope in (Environment.GetEnvironmentVariable("SCOPES") ?? "").Split(' '))
34+
options.Scope.Add(scope);
35+
});
36+
37+
builder.Services.AddAuthorization();
38+
// @snippet:step2:end
39+
40+
var app = builder.Build();
41+
app.UseAuthentication();
42+
app.UseAuthorization();
43+
44+
// @snippet:step3:start
45+
// @description Wire routes for sign-in redirect, user display, and sign-out
46+
app.MapGet("/", (HttpContext ctx) =>
47+
ctx.User.Identity?.IsAuthenticated == true
48+
? Results.Content(Views.RenderSignedInPage(ctx.User), "text/html")
49+
: Results.Content(Views.RenderSignedOutPage(), "text/html"));
50+
51+
app.MapGet("/login", () => Results.Challenge(
52+
new AuthenticationProperties { RedirectUri = "/" },
53+
[OpenIdConnectDefaults.AuthenticationScheme]));
54+
55+
app.MapGet("/logout", () => Results.SignOut(
56+
new AuthenticationProperties { RedirectUri = "/" },
57+
[CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]));
58+
// @snippet:step3:end
59+
60+
app.Run("https://localhost:4260");
61+
62+
public partial class Program { }
63+
64+
static class Views
65+
{
66+
public static string RenderSignedOutPage() => """
67+
<!doctype html>
68+
<html><head><title>SecureAuth .NET Auth Code Demo</title></head>
69+
<body>
70+
<h1>SecureAuth .NET Auth Code Demo</h1>
71+
<p><a href="/login">Sign in</a></p>
72+
</body></html>
73+
""";
74+
75+
public static string RenderSignedInPage(ClaimsPrincipal user)
76+
{
77+
var given = Escape(user.FindFirst("given_name")?.Value ?? "");
78+
var family = Escape(user.FindFirst("family_name")?.Value ?? "");
79+
var email = Escape(user.FindFirst("email")?.Value ?? "");
80+
var fullName = $"{given} {family}".Trim();
81+
var name = string.IsNullOrWhiteSpace(fullName) ? "there" : fullName;
82+
var emailSuffix = email.Length > 0 ? $" ({email})" : "";
83+
return $"""
84+
<!doctype html>
85+
<html><head><title>SecureAuth .NET Auth Code Demo</title></head>
86+
<body>
87+
<h1>SecureAuth .NET Auth Code Demo</h1>
88+
<p>Welcome, {name}{emailSuffix}</p>
89+
<p><a href="/logout">Sign out</a></p>
90+
</body></html>
91+
""";
92+
}
93+
94+
static string Escape(string s) => s
95+
.Replace("&", "&amp;")
96+
.Replace("<", "&lt;")
97+
.Replace(">", "&gt;")
98+
.Replace("\"", "&quot;")
99+
.Replace("'", "&#39;");
100+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
<PropertyGroup>
3+
<TargetFramework>net10.0</TargetFramework>
4+
<Nullable>enable</Nullable>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<AssemblyName>login-auth-code</AssemblyName>
7+
<RootNamespace>LoginAuthCode</RootNamespace>
8+
</PropertyGroup>
9+
<ItemGroup>
10+
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
11+
<PackageReference Include="DotNetEnv" Version="3.1.1" />
12+
</ItemGroup>
13+
</Project>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using System.Net;
2+
using Microsoft.AspNetCore.Authentication;
3+
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
4+
using Microsoft.AspNetCore.Mvc.Testing;
5+
using Microsoft.AspNetCore.TestHost;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
8+
using Xunit;
9+
10+
namespace LoginAuthCode.Tests;
11+
12+
public class AuthIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
13+
{
14+
private readonly WebApplicationFactory<Program> _factory;
15+
16+
public AuthIntegrationTests(WebApplicationFactory<Program> factory)
17+
{
18+
// Env vars needed so the OIDC middleware initializes at app boot time.
19+
Environment.SetEnvironmentVariable("ISSUER_URL", "https://idp.example");
20+
Environment.SetEnvironmentVariable("CLIENT_ID", "test-client");
21+
Environment.SetEnvironmentVariable("CLIENT_SECRET", "test-secret");
22+
Environment.SetEnvironmentVariable("SCOPES", "openid profile email");
23+
Environment.SetEnvironmentVariable("REDIRECT_URI", "https://localhost:4260/callback");
24+
Environment.SetEnvironmentVariable("POST_LOGOUT_URI", "https://localhost:4260/");
25+
26+
_factory = factory.WithWebHostBuilder(builder =>
27+
{
28+
builder.ConfigureTestServices(services =>
29+
{
30+
// Pre-populate OIDC Configuration so the middleware doesn't call the
31+
// discovery endpoint (which would fail — there's no real IdP here).
32+
services.Configure<OpenIdConnectOptions>(
33+
OpenIdConnectDefaults.AuthenticationScheme,
34+
options =>
35+
{
36+
options.Configuration = new OpenIdConnectConfiguration
37+
{
38+
Issuer = "https://idp.example",
39+
AuthorizationEndpoint = "https://idp.example/authorize",
40+
TokenEndpoint = "https://idp.example/token",
41+
EndSessionEndpoint = "https://idp.example/end_session",
42+
JwksUri = "https://idp.example/jwks",
43+
};
44+
});
45+
});
46+
});
47+
}
48+
49+
[Fact]
50+
public async Task Root_Unauthenticated_ShowsSignIn()
51+
{
52+
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
53+
{
54+
AllowAutoRedirect = false,
55+
});
56+
var res = await client.GetAsync("/");
57+
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
58+
var body = await res.Content.ReadAsStringAsync();
59+
Assert.Contains("Sign in", body);
60+
}
61+
62+
[Fact]
63+
public async Task Root_Authenticated_ShowsWelcome()
64+
{
65+
var factory = _factory.WithWebHostBuilder(builder =>
66+
{
67+
builder.ConfigureTestServices(services =>
68+
{
69+
services.AddAuthentication("Test")
70+
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", _ => { });
71+
services.PostConfigure<AuthenticationOptions>(options =>
72+
{
73+
options.DefaultScheme = "Test";
74+
options.DefaultAuthenticateScheme = "Test";
75+
});
76+
});
77+
});
78+
var client = factory.CreateClient();
79+
var res = await client.GetAsync("/");
80+
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
81+
var body = await res.Content.ReadAsStringAsync();
82+
Assert.Contains("Welcome, Test User (test@example.com)", body);
83+
}
84+
85+
[Fact]
86+
public async Task Login_ReturnsChallengeRedirectToAuthorizeEndpoint()
87+
{
88+
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
89+
{
90+
AllowAutoRedirect = false,
91+
});
92+
var res = await client.GetAsync("/login");
93+
Assert.True(
94+
res.StatusCode == HttpStatusCode.Redirect || res.StatusCode == HttpStatusCode.Found,
95+
$"Expected redirect, got {res.StatusCode}");
96+
var location = res.Headers.Location!.ToString();
97+
Assert.Contains("idp.example/authorize", location);
98+
Assert.Contains("client_id=test-client", location);
99+
Assert.Contains("response_type=code", location);
100+
Assert.Contains("code_challenge=", location);
101+
}
102+
103+
[Fact]
104+
public async Task Logout_RedirectsToEndSessionEndpoint()
105+
{
106+
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
107+
{
108+
AllowAutoRedirect = false,
109+
});
110+
var res = await client.GetAsync("/logout");
111+
Assert.True(
112+
res.StatusCode == HttpStatusCode.Redirect || res.StatusCode == HttpStatusCode.Found,
113+
$"Expected redirect, got {res.StatusCode}");
114+
var location = res.Headers.Location!.ToString();
115+
Assert.Contains("idp.example/end_session", location);
116+
}
117+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System.Security.Claims;
2+
using System.Text.Encodings.Web;
3+
using Microsoft.AspNetCore.Authentication;
4+
using Microsoft.Extensions.Logging;
5+
using Microsoft.Extensions.Options;
6+
7+
namespace LoginAuthCode.Tests;
8+
9+
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
10+
{
11+
public TestAuthHandler(
12+
IOptionsMonitor<AuthenticationSchemeOptions> options,
13+
ILoggerFactory logger,
14+
UrlEncoder encoder)
15+
: base(options, logger, encoder) { }
16+
17+
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
18+
{
19+
var claims = new[]
20+
{
21+
new Claim("given_name", "Test"),
22+
new Claim("family_name", "User"),
23+
new Claim("email", "test@example.com"),
24+
new Claim(ClaimTypes.NameIdentifier, "user-1"),
25+
};
26+
var identity = new ClaimsIdentity(claims, "Test");
27+
var principal = new ClaimsPrincipal(identity);
28+
var ticket = new AuthenticationTicket(principal, "Test");
29+
return Task.FromResult(AuthenticateResult.Success(ticket));
30+
}
31+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net10.0</TargetFramework>
4+
<IsPackable>false</IsPackable>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<AssemblyName>login-auth-code.tests</AssemblyName>
8+
<RootNamespace>LoginAuthCode.Tests</RootNamespace>
9+
</PropertyGroup>
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
12+
<PackageReference Include="xunit" Version="2.9.2" />
13+
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
14+
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
15+
</ItemGroup>
16+
<ItemGroup>
17+
<ProjectReference Include="..\src\login-auth-code.csproj" />
18+
</ItemGroup>
19+
</Project>

0 commit comments

Comments
 (0)