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
25 changes: 25 additions & 0 deletions samples/dotnet/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,28 @@ scenarios:
value: "{{SCOPES}}"
- label: "Grant"
value: "Authorization Code + PKCE"

server_token_refresh:
app_type: server_web
display_name: "Token Refresh"
grant: "Refresh Token"
description: "Use refresh tokens server-side to renew the user's access token without re-prompting them. Requires the offline_access scope."
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."
required_scopes:
- offline_access
run_command: "dotnet restore && dotnet run --project src"
config_rows:
- label: "Client ID"
value: "{{CLIENT_ID}}"
- label: "Client Secret"
value: "{{CLIENT_SECRET}}"
- label: "Issuer URL"
value: "{{ISSUER_URL}}"
- label: "Redirect URI"
value: "{{REDIRECT_URI}}"
- label: "Post-Logout Redirect URI"
value: "{{POST_LOGOUT_URI}}"
- label: "Scopes"
value: "{{SCOPES}}"
- label: "Grant"
value: "Authorization Code + PKCE (+ Refresh Token)"
11 changes: 11 additions & 0 deletions samples/dotnet/token-refresh/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CLIENT_ID=your-client-id
CLIENT_SECRET=your-client-secret
ISSUER_URL=https://your-tenant.us.connect.secureauth.com/your-workspace
SCOPES=openid profile email offline_access

# These are for configuring your SecureAuth application registration.
# The sample derives the callback/logout paths from the OIDC middleware's
# default options (CallbackPath = "/callback", SignedOutCallbackPath =
# "/signout-callback-oidc"), so it doesn't read these at runtime.
REDIRECT_URI=https://localhost:4260/callback
POST_LOGOUT_URI=https://localhost:4260/signout-callback-oidc
47 changes: 47 additions & 0 deletions samples/dotnet/token-refresh/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# .NET — Server Token Refresh

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.

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.

## 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 application configured as a **confidential server app** (with a client secret), with:
- Authorization Code grant type enabled
- Refresh Token grant type enabled
- `offline_access` scope allowed on the client

## Setup

1. Copy `.env.example` to `.env` and fill in your SecureAuth values (including `CLIENT_SECRET`)
2. `dotnet restore`
3. `dotnet run --project src`
4. Open https://localhost:4260

## What this demonstrates

- OIDC Authorization Code + PKCE flow with a confidential server client
- Refresh tokens persisted inside the encrypted auth cookie (`SaveTokens = true`)
- Manual refresh via a POST form that calls `Duende.IdentityModel`'s `RequestRefreshTokenAsync`
- Automatic refresh via the cookie auth `OnValidatePrincipal` event when the access token nears expiry
- Access-token expiry display (read via `HttpContext.GetTokenAsync("expires_at")`)
- RP-initiated logout via `Results.SignOut(...)` on Cookie + OIDC schemes

## Tests

```
dotnet test
```

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.

## 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.
- **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.
- **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).
- **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.
- **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).
239 changes: 239 additions & 0 deletions samples/dotnet/token-refresh/src/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
// @snippet:step1:start
// @description Import the ASP.NET Core OIDC/cookie middleware plus Duende.IdentityModel (used later for the refresh-token grant) and DotNetEnv for `.env` loading
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Duende.IdentityModel.Client;
using DotNetEnv;

// 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);

builder.Services.AddHttpClient();

// @snippet:step2:start
// @description Configure cookie auth + OpenID Connect; wire OnValidatePrincipal so every authenticated request can auto-refresh the access token
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.Events.OnValidatePrincipal = ValidatePrincipalWithRefresh;
})
.AddOpenIdConnect(options =>
{
options.Authority = RequireEnv("ISSUER_URL");
options.ClientId = RequireEnv("CLIENT_ID");
options.ClientSecret = RequireEnv("CLIENT_SECRET");
options.ResponseType = "code";
options.UsePkce = true;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
// Keep OIDC claim names (given_name, family_name, email) as-is — the default
// middleware remaps them to long xmlsoap URIs, which makes FindFirst("given_name")
// return null in user code.
options.MapInboundClaims = false;
options.CallbackPath = "/callback";
options.Scope.Clear();
foreach (var scope in RequireEnv("SCOPES").Split(' ', StringSplitOptions.RemoveEmptyEntries))
options.Scope.Add(scope);
});

builder.Services.AddAuthorization();
// @snippet:step2:end

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

// @snippet:step3:start
// @description Swap a refresh token for a fresh access token via the OIDC token endpoint
static async Task<TokenResponse> RefreshTokensAsync(
IHttpClientFactory httpFactory,
string refreshToken)
{
var http = httpFactory.CreateClient();
Comment thread
ksroda-sa marked this conversation as resolved.
var disco = await http.GetDiscoveryDocumentAsync(RequireEnv("ISSUER_URL"));
if (disco.IsError)
throw new InvalidOperationException($"Discovery failed: {disco.Error}");

Comment thread
ksroda-sa marked this conversation as resolved.
return await http.RequestRefreshTokenAsync(new RefreshTokenRequest
{
Address = disco.TokenEndpoint,
ClientId = RequireEnv("CLIENT_ID"),
ClientSecret = RequireEnv("CLIENT_SECRET"),
RefreshToken = refreshToken,
ClientCredentialStyle = ClientCredentialStyle.PostBody,
});
}
// @snippet:step3:end

// @snippet:step4:start
// @description Routes: user display, sign-in, manual refresh (POST /refresh), sign-out
app.MapGet("/", async (HttpContext ctx) =>
{
if (ctx.User.Identity?.IsAuthenticated != true)
return Results.Content(Views.RenderSignedOutPage(), "text/html");

var expiresAtRaw = await ctx.GetTokenAsync("expires_at");
var expiresAtDisplay = DateTimeOffset.TryParse(expiresAtRaw, out var dt)
? dt.ToLocalTime().ToString("h:mm:ss tt")
: "unknown";
return Results.Content(Views.RenderSignedInPage(ctx.User, expiresAtDisplay), "text/html");
});

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

app.MapPost("/refresh", async (HttpContext ctx, IHttpClientFactory httpFactory) =>
{
var authResult = await ctx.AuthenticateAsync();
if (!authResult.Succeeded || authResult.Properties is null)
return Results.Redirect("/");

var refreshToken = authResult.Properties.GetTokenValue("refresh_token");
if (refreshToken is null) return Results.Redirect("/");

try
{
var tokens = await RefreshTokensAsync(httpFactory, refreshToken);
if (tokens.IsError)
return Results.Content(
Views.RenderErrorPage(tokens.Error ?? "refresh failed"),
"text/html");

UpdateAuthProperties(authResult.Properties, tokens);
await ctx.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
authResult.Principal!,
authResult.Properties);
return Results.Redirect("/");
}
catch (Exception ex)
{
return Results.Content(Views.RenderErrorPage(ex.Message), "text/html");
}
});

app.MapGet("/logout", () => Results.SignOut(
new AuthenticationProperties { RedirectUri = "/" },
[CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]));

static void UpdateAuthProperties(AuthenticationProperties props, TokenResponse tokens)
{
if (tokens.AccessToken is not null)
props.UpdateTokenValue("access_token", tokens.AccessToken);
if (tokens.RefreshToken is not null)
props.UpdateTokenValue("refresh_token", tokens.RefreshToken);
if (tokens.IdentityToken is not null)
props.UpdateTokenValue("id_token", tokens.IdentityToken);
if (tokens.ExpiresIn > 0)
props.UpdateTokenValue(
"expires_at",
DateTimeOffset.UtcNow.AddSeconds(tokens.ExpiresIn).ToString("o"));
}
// @snippet:step4:end

// @snippet:step5:start
// @description Auto-refresh via the cookie auth OnValidatePrincipal event — runs on every authenticated request
static async Task ValidatePrincipalWithRefresh(CookieValidatePrincipalContext ctx)
{
var expiresAt = ctx.Properties.GetTokenValue("expires_at");
if (expiresAt is null || !DateTimeOffset.TryParse(expiresAt, out var expiry)) return;
if (expiry - DateTimeOffset.UtcNow > TimeSpan.FromSeconds(60)) return;

var refreshToken = ctx.Properties.GetTokenValue("refresh_token");
if (refreshToken is null)
{
ctx.RejectPrincipal();
return;
}

var httpFactory = ctx.HttpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
try
{
var tokens = await RefreshTokensAsync(httpFactory, refreshToken);
if (tokens.IsError)
{
ctx.RejectPrincipal();
return;
}

UpdateAuthProperties(ctx.Properties, tokens);
ctx.ShouldRenew = true;
}
Comment thread
ksroda-sa marked this conversation as resolved.
catch
{
ctx.RejectPrincipal();
}
}
// @snippet:step5:end

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

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

public partial class Program { }

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

public static string RenderSignedInPage(ClaimsPrincipal user, string expiresAtDisplay)
{
var given = Escape(user.FindFirst("given_name")?.Value ?? "");
var family = Escape(user.FindFirst("family_name")?.Value ?? "");
var email = Escape(user.FindFirst("email")?.Value ?? "");
var fullName = $"{given} {family}".Trim();
var name = string.IsNullOrWhiteSpace(fullName) ? "there" : fullName;
var emailSuffix = email.Length > 0 ? $" ({email})" : "";
return $"""
<!doctype html>
<html><head><title>SecureAuth .NET Token Refresh Demo</title></head>
<body>
<h1>SecureAuth .NET Token Refresh Demo</h1>
<p>Welcome, {name}{emailSuffix}</p>
<p>Access token expires at: {Escape(expiresAtDisplay)}</p>
<form method="POST" action="/refresh">
<button type="submit">Refresh token now</button>
</form>
<p><a href="/logout">Sign out</a></p>
</body></html>
""";
}

public static string RenderErrorPage(string message) => $"""
<!doctype html>
<html><head><title>SecureAuth .NET Token Refresh Demo</title></head>
<body>
<h1>SecureAuth .NET Token Refresh Demo</h1>
<div style="color: red">
<p>Error: {Escape(message)}</p>
<p><a href="/login">Try again</a></p>
</div>
</body></html>
""";

static string Escape(string s) => s
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
14 changes: 14 additions & 0 deletions samples/dotnet/token-refresh/src/token-refresh.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>token-refresh</AssemblyName>
<RootNamespace>TokenRefresh</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Duende.IdentityModel" Version="7.0.0" />
</ItemGroup>
</Project>
Loading
Loading