Skip to content

Commit f413fb3

Browse files
authored
Merge pull request #30 from SecureAuthCorp/feature/dotnet-token-refresh-sample
Add .NET server_token_refresh sample
2 parents d378929 + 3ad2b10 commit f413fb3

13 files changed

Lines changed: 750 additions & 4 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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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.
47+
- **Discovery caching:** for readability, `RefreshTokensAsync` fetches the OpenID discovery document on every call. Each refreshed access token costs an extra network round-trip. Production apps should cache discovery for the lifetime of the process (or reuse the `ConfigurationManager<OpenIdConnectConfiguration>` that the OIDC middleware already maintains).
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// @snippet:step1:start
2+
// @description Import the ASP.NET Core OIDC/cookie middleware plus Duende.IdentityModel (used later for the refresh-token grant) and DotNetEnv for `.env` loading
3+
using System.Security.Claims;
4+
using Microsoft.AspNetCore.Authentication;
5+
using Microsoft.AspNetCore.Authentication.Cookies;
6+
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
7+
using Duende.IdentityModel.Client;
8+
using DotNetEnv;
9+
10+
// Searches parent dirs so `dotnet run --project src` finds the .env at the sample root. Existing env vars win.
11+
Env.TraversePath().NoClobber().Load();
12+
// @snippet:step1:end
13+
14+
var builder = WebApplication.CreateBuilder(args);
15+
16+
builder.Services.AddHttpClient();
17+
18+
// @snippet:step2:start
19+
// @description Configure cookie auth + OpenID Connect; wire OnValidatePrincipal so every authenticated request can auto-refresh the access token
20+
builder.Services.AddAuthentication(options =>
21+
{
22+
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
23+
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
24+
})
25+
.AddCookie(options =>
26+
{
27+
options.Events.OnValidatePrincipal = ValidatePrincipalWithRefresh;
28+
})
29+
.AddOpenIdConnect(options =>
30+
{
31+
options.Authority = RequireEnv("ISSUER_URL");
32+
options.ClientId = RequireEnv("CLIENT_ID");
33+
options.ClientSecret = RequireEnv("CLIENT_SECRET");
34+
options.ResponseType = "code";
35+
options.UsePkce = true;
36+
options.SaveTokens = true;
37+
options.GetClaimsFromUserInfoEndpoint = true;
38+
// Keep OIDC claim names (given_name, family_name, email) as-is — the default
39+
// middleware remaps them to long xmlsoap URIs, which makes FindFirst("given_name")
40+
// return null in user code.
41+
options.MapInboundClaims = false;
42+
options.CallbackPath = "/callback";
43+
options.Scope.Clear();
44+
foreach (var scope in RequireEnv("SCOPES").Split(' ', StringSplitOptions.RemoveEmptyEntries))
45+
options.Scope.Add(scope);
46+
});
47+
48+
builder.Services.AddAuthorization();
49+
// @snippet:step2:end
50+
51+
var app = builder.Build();
52+
app.UseAuthentication();
53+
app.UseAuthorization();
54+
55+
// @snippet:step3:start
56+
// @description Swap a refresh token for a fresh access token via the OIDC token endpoint
57+
static async Task<TokenResponse> RefreshTokensAsync(
58+
IHttpClientFactory httpFactory,
59+
string refreshToken)
60+
{
61+
var http = httpFactory.CreateClient();
62+
var disco = await http.GetDiscoveryDocumentAsync(RequireEnv("ISSUER_URL"));
63+
if (disco.IsError)
64+
throw new InvalidOperationException($"Discovery failed: {disco.Error}");
65+
66+
return await http.RequestRefreshTokenAsync(new RefreshTokenRequest
67+
{
68+
Address = disco.TokenEndpoint,
69+
ClientId = RequireEnv("CLIENT_ID"),
70+
ClientSecret = RequireEnv("CLIENT_SECRET"),
71+
RefreshToken = refreshToken,
72+
ClientCredentialStyle = ClientCredentialStyle.PostBody,
73+
});
74+
}
75+
// @snippet:step3:end
76+
77+
// @snippet:step4:start
78+
// @description Routes: user display, sign-in, manual refresh (POST /refresh), sign-out
79+
app.MapGet("/", async (HttpContext ctx) =>
80+
{
81+
if (ctx.User.Identity?.IsAuthenticated != true)
82+
return Results.Content(Views.RenderSignedOutPage(), "text/html");
83+
84+
var expiresAtRaw = await ctx.GetTokenAsync("expires_at");
85+
var expiresAtDisplay = DateTimeOffset.TryParse(expiresAtRaw, out var dt)
86+
? dt.ToLocalTime().ToString("h:mm:ss tt")
87+
: "unknown";
88+
return Results.Content(Views.RenderSignedInPage(ctx.User, expiresAtDisplay), "text/html");
89+
});
90+
91+
app.MapGet("/login", () => Results.Challenge(
92+
new AuthenticationProperties { RedirectUri = "/" },
93+
[OpenIdConnectDefaults.AuthenticationScheme]));
94+
95+
app.MapPost("/refresh", async (HttpContext ctx, IHttpClientFactory httpFactory) =>
96+
{
97+
var authResult = await ctx.AuthenticateAsync();
98+
if (!authResult.Succeeded || authResult.Properties is null)
99+
return Results.Redirect("/");
100+
101+
var refreshToken = authResult.Properties.GetTokenValue("refresh_token");
102+
if (refreshToken is null) return Results.Redirect("/");
103+
104+
try
105+
{
106+
var tokens = await RefreshTokensAsync(httpFactory, refreshToken);
107+
if (tokens.IsError)
108+
return Results.Content(
109+
Views.RenderErrorPage(tokens.Error ?? "refresh failed"),
110+
"text/html");
111+
112+
UpdateAuthProperties(authResult.Properties, tokens);
113+
await ctx.SignInAsync(
114+
CookieAuthenticationDefaults.AuthenticationScheme,
115+
authResult.Principal!,
116+
authResult.Properties);
117+
return Results.Redirect("/");
118+
}
119+
catch (Exception ex)
120+
{
121+
return Results.Content(Views.RenderErrorPage(ex.Message), "text/html");
122+
}
123+
});
124+
125+
app.MapGet("/logout", () => Results.SignOut(
126+
new AuthenticationProperties { RedirectUri = "/" },
127+
[CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]));
128+
129+
static void UpdateAuthProperties(AuthenticationProperties props, TokenResponse tokens)
130+
{
131+
if (tokens.AccessToken is not null)
132+
props.UpdateTokenValue("access_token", tokens.AccessToken);
133+
if (tokens.RefreshToken is not null)
134+
props.UpdateTokenValue("refresh_token", tokens.RefreshToken);
135+
if (tokens.IdentityToken is not null)
136+
props.UpdateTokenValue("id_token", tokens.IdentityToken);
137+
if (tokens.ExpiresIn > 0)
138+
props.UpdateTokenValue(
139+
"expires_at",
140+
DateTimeOffset.UtcNow.AddSeconds(tokens.ExpiresIn).ToString("o"));
141+
}
142+
// @snippet:step4:end
143+
144+
// @snippet:step5:start
145+
// @description Auto-refresh via the cookie auth OnValidatePrincipal event — runs on every authenticated request
146+
static async Task ValidatePrincipalWithRefresh(CookieValidatePrincipalContext ctx)
147+
{
148+
var expiresAt = ctx.Properties.GetTokenValue("expires_at");
149+
if (expiresAt is null || !DateTimeOffset.TryParse(expiresAt, out var expiry)) return;
150+
if (expiry - DateTimeOffset.UtcNow > TimeSpan.FromSeconds(60)) return;
151+
152+
var refreshToken = ctx.Properties.GetTokenValue("refresh_token");
153+
if (refreshToken is null)
154+
{
155+
ctx.RejectPrincipal();
156+
return;
157+
}
158+
159+
var httpFactory = ctx.HttpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
160+
try
161+
{
162+
var tokens = await RefreshTokensAsync(httpFactory, refreshToken);
163+
if (tokens.IsError)
164+
{
165+
ctx.RejectPrincipal();
166+
return;
167+
}
168+
169+
UpdateAuthProperties(ctx.Properties, tokens);
170+
ctx.ShouldRenew = true;
171+
}
172+
catch
173+
{
174+
ctx.RejectPrincipal();
175+
}
176+
}
177+
// @snippet:step5:end
178+
179+
app.Run("https://localhost:4260");
180+
181+
static string RequireEnv(string name) =>
182+
Environment.GetEnvironmentVariable(name)
183+
?? throw new InvalidOperationException($"Missing required env var: {name}");
184+
185+
public partial class Program { }
186+
187+
static class Views
188+
{
189+
public static string RenderSignedOutPage() => """
190+
<!doctype html>
191+
<html><head><title>SecureAuth .NET Token Refresh Demo</title></head>
192+
<body>
193+
<h1>SecureAuth .NET Token Refresh Demo</h1>
194+
<p><a href="/login">Sign in</a></p>
195+
</body></html>
196+
""";
197+
198+
public static string RenderSignedInPage(ClaimsPrincipal user, string expiresAtDisplay)
199+
{
200+
var given = Escape(user.FindFirst("given_name")?.Value ?? "");
201+
var family = Escape(user.FindFirst("family_name")?.Value ?? "");
202+
var email = Escape(user.FindFirst("email")?.Value ?? "");
203+
var fullName = $"{given} {family}".Trim();
204+
var name = string.IsNullOrWhiteSpace(fullName) ? "there" : fullName;
205+
var emailSuffix = email.Length > 0 ? $" ({email})" : "";
206+
return $"""
207+
<!doctype html>
208+
<html><head><title>SecureAuth .NET Token Refresh Demo</title></head>
209+
<body>
210+
<h1>SecureAuth .NET Token Refresh Demo</h1>
211+
<p>Welcome, {name}{emailSuffix}</p>
212+
<p>Access token expires at: {Escape(expiresAtDisplay)}</p>
213+
<form method="POST" action="/refresh">
214+
<button type="submit">Refresh token now</button>
215+
</form>
216+
<p><a href="/logout">Sign out</a></p>
217+
</body></html>
218+
""";
219+
}
220+
221+
public static string RenderErrorPage(string message) => $"""
222+
<!doctype html>
223+
<html><head><title>SecureAuth .NET Token Refresh Demo</title></head>
224+
<body>
225+
<h1>SecureAuth .NET Token Refresh Demo</h1>
226+
<div style="color: red">
227+
<p>Error: {Escape(message)}</p>
228+
<p><a href="/login">Try again</a></p>
229+
</div>
230+
</body></html>
231+
""";
232+
233+
static string Escape(string s) => s
234+
.Replace("&", "&amp;")
235+
.Replace("<", "&lt;")
236+
.Replace(">", "&gt;")
237+
.Replace("\"", "&quot;")
238+
.Replace("'", "&#39;");
239+
}
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)