-
Notifications
You must be signed in to change notification settings - Fork 0
Add .NET server_token_refresh sample #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
0d8e2dd
feat: add .NET server_token_refresh sample
ksroda-sa 922aa12
docs(dotnet/token-refresh): flag discovery-caching gap in production …
ksroda-sa f428946
fix
ksroda-sa 2443272
update snippets
ksroda-sa 7c2e2c4
fix: handle /refresh exceptions and use POST body client auth
ksroda-sa bc8e55f
sync snippets
ksroda-sa ee0f977
fix: leave install empty for non-npm projects
ksroda-sa 3ad2b10
refactor: surface Duende.IdentityModel import in token-refresh step 1
ksroda-sa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| var disco = await http.GetDiscoveryDocumentAsync(RequireEnv("ISSUER_URL")); | ||
| if (disco.IsError) | ||
| throw new InvalidOperationException($"Discovery failed: {disco.Error}"); | ||
|
|
||
|
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; | ||
| } | ||
|
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("&", "&") | ||
| .Replace("<", "<") | ||
| .Replace(">", ">") | ||
| .Replace("\"", """) | ||
| .Replace("'", "'"); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.