Skip to content

Commit 0d8e2dd

Browse files
committed
feat: add .NET server_token_refresh sample
1 parent d378929 commit 0d8e2dd

12 files changed

Lines changed: 733 additions & 0 deletions

samples/dotnet/manifest.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,28 @@ scenarios:
2828
value: "{{SCOPES}}"
2929
- label: "Grant"
3030
value: "Authorization Code + PKCE"
31+
32+
server_token_refresh:
33+
app_type: server_web
34+
display_name: "Token Refresh"
35+
grant: "Refresh Token"
36+
description: "Use refresh tokens server-side to renew the user's access token without re-prompting them. Requires the offline_access scope."
37+
callout: "Requires refresh_token grant type enabled in workspace [OAuth settings](workspace-oauth-general) and in the application's [Grant Types](workspace-applications-oauth). Also requires the offline_access scope."
38+
required_scopes:
39+
- offline_access
40+
run_command: "dotnet restore && dotnet run --project src"
41+
config_rows:
42+
- label: "Client ID"
43+
value: "{{CLIENT_ID}}"
44+
- label: "Client Secret"
45+
value: "{{CLIENT_SECRET}}"
46+
- label: "Issuer URL"
47+
value: "{{ISSUER_URL}}"
48+
- label: "Redirect URI"
49+
value: "{{REDIRECT_URI}}"
50+
- label: "Post-Logout Redirect URI"
51+
value: "{{POST_LOGOUT_URI}}"
52+
- label: "Scopes"
53+
value: "{{SCOPES}}"
54+
- label: "Grant"
55+
value: "Authorization Code + PKCE (+ Refresh Token)"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
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+
SCOPES=openid profile email offline_access
5+
6+
# These are for configuring your SecureAuth application registration.
7+
# The sample derives the callback/logout paths from the OIDC middleware's
8+
# default options (CallbackPath = "/callback", SignedOutCallbackPath =
9+
# "/signout-callback-oidc"), so it doesn't read these at runtime.
10+
REDIRECT_URI=https://localhost:4260/callback
11+
POST_LOGOUT_URI=https://localhost:4260/signout-callback-oidc
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# .NET — Server Token Refresh
2+
3+
Minimal ASP.NET Core 10 Minimal-APIs app demonstrating server-side token refresh using [`Duende.IdentityModel`](https://github.com/DuendeSoftware/foss/tree/main/identity-model) on top of the built-in `Microsoft.AspNetCore.Authentication.OpenIdConnect` middleware. After login, the refresh token is stored in the encrypted auth cookie; a "Refresh token now" form posts to `/refresh` for manual refresh, and an `OnValidatePrincipal` event auto-refreshes the cookie on every authenticated request when the access token is within 60s of expiring.
4+
5+
The refresh token never reaches the browser as a raw value — it lives inside the encrypted auth cookie, which ASP.NET Core data protection signs and encrypts.
6+
7+
## Prerequisites
8+
9+
- .NET 10 SDK installed (`dotnet --version` → 10.x)
10+
- `dotnet dev-certs https --trust` — run once per machine to trust the local dev HTTPS cert
11+
- A SecureAuth application configured as a **confidential server app** (with a client secret), with:
12+
- Authorization Code grant type enabled
13+
- Refresh Token grant type enabled
14+
- `offline_access` scope allowed on the client
15+
16+
## Setup
17+
18+
1. Copy `.env.example` to `.env` and fill in your SecureAuth values (including `CLIENT_SECRET`)
19+
2. `dotnet restore`
20+
3. `dotnet run --project src`
21+
4. Open https://localhost:4260
22+
23+
## What this demonstrates
24+
25+
- OIDC Authorization Code + PKCE flow with a confidential server client
26+
- Refresh tokens persisted inside the encrypted auth cookie (`SaveTokens = true`)
27+
- Manual refresh via a POST form that calls `Duende.IdentityModel`'s `RequestRefreshTokenAsync`
28+
- Automatic refresh via the cookie auth `OnValidatePrincipal` event when the access token nears expiry
29+
- Access-token expiry display (read via `HttpContext.GetTokenAsync("expires_at")`)
30+
- RP-initiated logout via `Results.SignOut(...)` on Cookie + OIDC schemes
31+
32+
## Tests
33+
34+
```
35+
dotnet test
36+
```
37+
38+
Six tests covering `/` unauth, `/` authenticated, `/login` challenge, `/refresh` happy path, `/refresh` error path, `/logout`. The auto-refresh middleware is not covered by a unit test — it calls the same refresh helper as `/refresh`, and is verified by manual smoke.
39+
40+
## Production notes
41+
42+
- Replace the dev cert with a real TLS certificate (via a reverse proxy or configured Kestrel endpoint).
43+
- 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.
44+
- **CSRF protection:** `POST /refresh` is not CSRF-protected. For production, enable ASP.NET Core antiforgery middleware, switch cookies to `SameSite=Strict`, or use a double-submit cookie pattern.
45+
- **Concurrent refreshes:** two requests may race on the refresh token with rotation-enforcing IdPs. Production apps should serialize refresh per session (e.g. via `SemaphoreSlim` keyed by session id).
46+
- **Auto-refresh scope:** `OnValidatePrincipal` fires for every authenticated request including static assets and health checks. In production, consider wrapping the refresh logic so it only runs for routes that actually use the access token.
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
using System.Security.Claims;
2+
using Microsoft.AspNetCore.Authentication;
3+
using Microsoft.AspNetCore.Authentication.Cookies;
4+
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
5+
using Duende.IdentityModel.Client;
6+
using DotNetEnv;
7+
8+
// @snippet:step1:start
9+
// @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.
10+
Env.TraversePath().NoClobber().Load();
11+
// @snippet:step1:end
12+
13+
var builder = WebApplication.CreateBuilder(args);
14+
15+
builder.Services.AddHttpClient();
16+
17+
// @snippet:step2:start
18+
// @description Configure cookie auth + OpenID Connect; wire OnValidatePrincipal so every authenticated request can auto-refresh the access token
19+
builder.Services.AddAuthentication(options =>
20+
{
21+
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
22+
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
23+
})
24+
.AddCookie(options =>
25+
{
26+
options.Events.OnValidatePrincipal = ValidatePrincipalWithRefresh;
27+
})
28+
.AddOpenIdConnect(options =>
29+
{
30+
options.Authority = RequireEnv("ISSUER_URL");
31+
options.ClientId = RequireEnv("CLIENT_ID");
32+
options.ClientSecret = RequireEnv("CLIENT_SECRET");
33+
options.ResponseType = "code";
34+
options.UsePkce = true;
35+
options.SaveTokens = true;
36+
options.GetClaimsFromUserInfoEndpoint = true;
37+
// Keep OIDC claim names (given_name, family_name, email) as-is — the default
38+
// middleware remaps them to long xmlsoap URIs, which makes FindFirst("given_name")
39+
// return null in user code.
40+
options.MapInboundClaims = false;
41+
options.CallbackPath = "/callback";
42+
options.Scope.Clear();
43+
foreach (var scope in RequireEnv("SCOPES").Split(' ', StringSplitOptions.RemoveEmptyEntries))
44+
options.Scope.Add(scope);
45+
});
46+
47+
builder.Services.AddAuthorization();
48+
// @snippet:step2:end
49+
50+
var app = builder.Build();
51+
app.UseAuthentication();
52+
app.UseAuthorization();
53+
54+
// @snippet:step3:start
55+
// @description Swap a refresh token for a fresh access token via the OIDC token endpoint
56+
static async Task<TokenResponse> RefreshTokensAsync(
57+
IHttpClientFactory httpFactory,
58+
string refreshToken)
59+
{
60+
var http = httpFactory.CreateClient();
61+
var disco = await http.GetDiscoveryDocumentAsync(RequireEnv("ISSUER_URL"));
62+
if (disco.IsError)
63+
throw new InvalidOperationException($"Discovery failed: {disco.Error}");
64+
65+
return await http.RequestRefreshTokenAsync(new RefreshTokenRequest
66+
{
67+
Address = disco.TokenEndpoint,
68+
ClientId = RequireEnv("CLIENT_ID"),
69+
ClientSecret = RequireEnv("CLIENT_SECRET"),
70+
RefreshToken = refreshToken,
71+
});
72+
}
73+
// @snippet:step3:end
74+
75+
// @snippet:step4:start
76+
// @description Routes: user display, sign-in, manual refresh (POST /refresh), sign-out
77+
app.MapGet("/", async (HttpContext ctx) =>
78+
{
79+
if (ctx.User.Identity?.IsAuthenticated != true)
80+
return Results.Content(Views.RenderSignedOutPage(), "text/html");
81+
82+
var expiresAtRaw = await ctx.GetTokenAsync("expires_at");
83+
var expiresAtDisplay = DateTimeOffset.TryParse(expiresAtRaw, out var dt)
84+
? dt.ToLocalTime().ToString("h:mm:ss tt")
85+
: "unknown";
86+
return Results.Content(Views.RenderSignedInPage(ctx.User, expiresAtDisplay), "text/html");
87+
});
88+
89+
app.MapGet("/login", () => Results.Challenge(
90+
new AuthenticationProperties { RedirectUri = "/" },
91+
[OpenIdConnectDefaults.AuthenticationScheme]));
92+
93+
app.MapPost("/refresh", async (HttpContext ctx, IHttpClientFactory httpFactory) =>
94+
{
95+
var authResult = await ctx.AuthenticateAsync();
96+
if (!authResult.Succeeded || authResult.Properties is null)
97+
return Results.Redirect("/");
98+
99+
var refreshToken = authResult.Properties.GetTokenValue("refresh_token");
100+
if (refreshToken is null) return Results.Redirect("/");
101+
102+
var tokens = await RefreshTokensAsync(httpFactory, refreshToken);
103+
if (tokens.IsError)
104+
return Results.Content(Views.RenderErrorPage(tokens.Error ?? "refresh failed"), "text/html");
105+
106+
UpdateAuthProperties(authResult.Properties, tokens);
107+
await ctx.SignInAsync(
108+
CookieAuthenticationDefaults.AuthenticationScheme,
109+
authResult.Principal!,
110+
authResult.Properties);
111+
return Results.Redirect("/");
112+
});
113+
114+
app.MapGet("/logout", () => Results.SignOut(
115+
new AuthenticationProperties { RedirectUri = "/" },
116+
[CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]));
117+
118+
static void UpdateAuthProperties(AuthenticationProperties props, TokenResponse tokens)
119+
{
120+
if (tokens.AccessToken is not null)
121+
props.UpdateTokenValue("access_token", tokens.AccessToken);
122+
if (tokens.RefreshToken is not null)
123+
props.UpdateTokenValue("refresh_token", tokens.RefreshToken);
124+
if (tokens.IdentityToken is not null)
125+
props.UpdateTokenValue("id_token", tokens.IdentityToken);
126+
if (tokens.ExpiresIn > 0)
127+
props.UpdateTokenValue(
128+
"expires_at",
129+
DateTimeOffset.UtcNow.AddSeconds(tokens.ExpiresIn).ToString("o"));
130+
}
131+
// @snippet:step4:end
132+
133+
// @snippet:step5:start
134+
// @description Auto-refresh via the cookie auth OnValidatePrincipal event — runs on every authenticated request
135+
static async Task ValidatePrincipalWithRefresh(CookieValidatePrincipalContext ctx)
136+
{
137+
var expiresAt = ctx.Properties.GetTokenValue("expires_at");
138+
if (expiresAt is null || !DateTimeOffset.TryParse(expiresAt, out var expiry)) return;
139+
if (expiry - DateTimeOffset.UtcNow > TimeSpan.FromSeconds(60)) return;
140+
141+
var refreshToken = ctx.Properties.GetTokenValue("refresh_token");
142+
if (refreshToken is null)
143+
{
144+
ctx.RejectPrincipal();
145+
return;
146+
}
147+
148+
var httpFactory = ctx.HttpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
149+
try
150+
{
151+
var tokens = await RefreshTokensAsync(httpFactory, refreshToken);
152+
if (tokens.IsError)
153+
{
154+
ctx.RejectPrincipal();
155+
return;
156+
}
157+
158+
UpdateAuthProperties(ctx.Properties, tokens);
159+
ctx.ShouldRenew = true;
160+
}
161+
catch
162+
{
163+
ctx.RejectPrincipal();
164+
}
165+
}
166+
// @snippet:step5:end
167+
168+
app.Run("https://localhost:4260");
169+
170+
static string RequireEnv(string name) =>
171+
Environment.GetEnvironmentVariable(name)
172+
?? throw new InvalidOperationException($"Missing required env var: {name}");
173+
174+
public partial class Program { }
175+
176+
static class Views
177+
{
178+
public static string RenderSignedOutPage() => """
179+
<!doctype html>
180+
<html><head><title>SecureAuth .NET Token Refresh Demo</title></head>
181+
<body>
182+
<h1>SecureAuth .NET Token Refresh Demo</h1>
183+
<p><a href="/login">Sign in</a></p>
184+
</body></html>
185+
""";
186+
187+
public static string RenderSignedInPage(ClaimsPrincipal user, string expiresAtDisplay)
188+
{
189+
var given = Escape(user.FindFirst("given_name")?.Value ?? "");
190+
var family = Escape(user.FindFirst("family_name")?.Value ?? "");
191+
var email = Escape(user.FindFirst("email")?.Value ?? "");
192+
var fullName = $"{given} {family}".Trim();
193+
var name = string.IsNullOrWhiteSpace(fullName) ? "there" : fullName;
194+
var emailSuffix = email.Length > 0 ? $" ({email})" : "";
195+
return $"""
196+
<!doctype html>
197+
<html><head><title>SecureAuth .NET Token Refresh Demo</title></head>
198+
<body>
199+
<h1>SecureAuth .NET Token Refresh Demo</h1>
200+
<p>Welcome, {name}{emailSuffix}</p>
201+
<p>Access token expires at: {Escape(expiresAtDisplay)}</p>
202+
<form method="POST" action="/refresh">
203+
<button type="submit">Refresh token now</button>
204+
</form>
205+
<p><a href="/logout">Sign out</a></p>
206+
</body></html>
207+
""";
208+
}
209+
210+
public static string RenderErrorPage(string message) => $"""
211+
<!doctype html>
212+
<html><head><title>SecureAuth .NET Token Refresh Demo</title></head>
213+
<body>
214+
<h1>SecureAuth .NET Token Refresh Demo</h1>
215+
<div style="color: red">
216+
<p>Error: {Escape(message)}</p>
217+
<p><a href="/login">Try again</a></p>
218+
</div>
219+
</body></html>
220+
""";
221+
222+
static string Escape(string s) => s
223+
.Replace("&", "&amp;")
224+
.Replace("<", "&lt;")
225+
.Replace(">", "&gt;")
226+
.Replace("\"", "&quot;")
227+
.Replace("'", "&#39;");
228+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
<PropertyGroup>
3+
<TargetFramework>net10.0</TargetFramework>
4+
<Nullable>enable</Nullable>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<AssemblyName>token-refresh</AssemblyName>
7+
<RootNamespace>TokenRefresh</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+
<PackageReference Include="Duende.IdentityModel" Version="7.0.0" />
13+
</ItemGroup>
14+
</Project>

0 commit comments

Comments
 (0)