|
322 | 322 | "steps": [ |
323 | 323 | { |
324 | 324 | "step": 1, |
325 | | - "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.", |
326 | | - "code": "Env.TraversePath().NoClobber().Load();", |
| 325 | + "description": "Import the ASP.NET Core OIDC/cookie middleware plus Duende.IdentityModel (used later for the refresh-token grant) and DotNetEnv for `.env` loading", |
| 326 | + "code": "using System.Security.Claims;\nusing Microsoft.AspNetCore.Authentication;\nusing Microsoft.AspNetCore.Authentication.Cookies;\nusing Microsoft.AspNetCore.Authentication.OpenIdConnect;\nusing Duende.IdentityModel.Client;\nusing DotNetEnv;\n\n// Searches parent dirs so `dotnet run --project src` finds the .env at the sample root. Existing env vars win.\nEnv.TraversePath().NoClobber().Load();", |
327 | 327 | "file": "src/Program.cs", |
328 | 328 | "lang": "csharp", |
329 | | - "lines": "8-10" |
| 329 | + "lines": "1-11" |
330 | 330 | }, |
331 | 331 | { |
332 | 332 | "step": 2, |
333 | 333 | "description": "Configure cookie auth + OpenID Connect; wire OnValidatePrincipal so every authenticated request can auto-refresh the access token", |
334 | 334 | "code": "builder.Services.AddAuthentication(options =>\n {\n options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;\n options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;\n })\n .AddCookie(options =>\n {\n options.Events.OnValidatePrincipal = ValidatePrincipalWithRefresh;\n })\n .AddOpenIdConnect(options =>\n {\n options.Authority = \"{{ISSUER_URL}}\";\n options.ClientId = \"{{CLIENT_ID}}\";\n options.ClientSecret = \"{{CLIENT_SECRET}}\";\n options.ResponseType = \"code\";\n options.UsePkce = true;\n options.SaveTokens = true;\n options.GetClaimsFromUserInfoEndpoint = true;\n // Keep OIDC claim names (given_name, family_name, email) as-is — the default\n // middleware remaps them to long xmlsoap URIs, which makes FindFirst(\"given_name\")\n // return null in user code.\n options.MapInboundClaims = false;\n options.CallbackPath = \"/callback\";\n options.Scope.Clear();\n foreach (var scope in \"{{SCOPES}}\".Split(' ', StringSplitOptions.RemoveEmptyEntries))\n options.Scope.Add(scope);\n });\n\nbuilder.Services.AddAuthorization();", |
335 | 335 | "file": "src/Program.cs", |
336 | 336 | "lang": "csharp", |
337 | | - "lines": "17-47" |
| 337 | + "lines": "18-48" |
338 | 338 | }, |
339 | 339 | { |
340 | 340 | "step": 3, |
341 | 341 | "description": "Swap a refresh token for a fresh access token via the OIDC token endpoint", |
342 | 342 | "code": "static async Task<TokenResponse> RefreshTokensAsync(\n IHttpClientFactory httpFactory,\n string refreshToken)\n{\n var http = httpFactory.CreateClient();\n var disco = await http.GetDiscoveryDocumentAsync(\"{{ISSUER_URL}}\");\n if (disco.IsError)\n throw new InvalidOperationException($\"Discovery failed: {disco.Error}\");\n\n return await http.RequestRefreshTokenAsync(new RefreshTokenRequest\n {\n Address = disco.TokenEndpoint,\n ClientId = \"{{CLIENT_ID}}\",\n ClientSecret = \"{{CLIENT_SECRET}}\",\n RefreshToken = refreshToken,\n ClientCredentialStyle = ClientCredentialStyle.PostBody,\n });\n}", |
343 | 343 | "file": "src/Program.cs", |
344 | 344 | "lang": "csharp", |
345 | | - "lines": "54-73" |
| 345 | + "lines": "55-74" |
346 | 346 | }, |
347 | 347 | { |
348 | 348 | "step": 4, |
349 | 349 | "description": "Routes: user display, sign-in, manual refresh (POST /refresh), sign-out", |
350 | 350 | "code": "app.MapGet(\"/\", async (HttpContext ctx) =>\n{\n if (ctx.User.Identity?.IsAuthenticated != true)\n return Results.Content(Views.RenderSignedOutPage(), \"text/html\");\n\n var expiresAtRaw = await ctx.GetTokenAsync(\"expires_at\");\n var expiresAtDisplay = DateTimeOffset.TryParse(expiresAtRaw, out var dt)\n ? dt.ToLocalTime().ToString(\"h:mm:ss tt\")\n : \"unknown\";\n return Results.Content(Views.RenderSignedInPage(ctx.User, expiresAtDisplay), \"text/html\");\n});\n\napp.MapGet(\"/login\", () => Results.Challenge(\n new AuthenticationProperties { RedirectUri = \"/\" },\n [OpenIdConnectDefaults.AuthenticationScheme]));\n\napp.MapPost(\"/refresh\", async (HttpContext ctx, IHttpClientFactory httpFactory) =>\n{\n var authResult = await ctx.AuthenticateAsync();\n if (!authResult.Succeeded || authResult.Properties is null)\n return Results.Redirect(\"/\");\n\n var refreshToken = authResult.Properties.GetTokenValue(\"refresh_token\");\n if (refreshToken is null) return Results.Redirect(\"/\");\n\n try\n {\n var tokens = await RefreshTokensAsync(httpFactory, refreshToken);\n if (tokens.IsError)\n return Results.Content(\n Views.RenderErrorPage(tokens.Error ?? \"refresh failed\"),\n \"text/html\");\n\n UpdateAuthProperties(authResult.Properties, tokens);\n await ctx.SignInAsync(\n CookieAuthenticationDefaults.AuthenticationScheme,\n authResult.Principal!,\n authResult.Properties);\n return Results.Redirect(\"/\");\n }\n catch (Exception ex)\n {\n return Results.Content(Views.RenderErrorPage(ex.Message), \"text/html\");\n }\n});\n\napp.MapGet(\"/logout\", () => Results.SignOut(\n new AuthenticationProperties { RedirectUri = \"/\" },\n [CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]));\n\nstatic void UpdateAuthProperties(AuthenticationProperties props, TokenResponse tokens)\n{\n if (tokens.AccessToken is not null)\n props.UpdateTokenValue(\"access_token\", tokens.AccessToken);\n if (tokens.RefreshToken is not null)\n props.UpdateTokenValue(\"refresh_token\", tokens.RefreshToken);\n if (tokens.IdentityToken is not null)\n props.UpdateTokenValue(\"id_token\", tokens.IdentityToken);\n if (tokens.ExpiresIn > 0)\n props.UpdateTokenValue(\n \"expires_at\",\n DateTimeOffset.UtcNow.AddSeconds(tokens.ExpiresIn).ToString(\"o\"));\n}", |
351 | 351 | "file": "src/Program.cs", |
352 | 352 | "lang": "csharp", |
353 | | - "lines": "76-140" |
| 353 | + "lines": "77-141" |
354 | 354 | }, |
355 | 355 | { |
356 | 356 | "step": 5, |
357 | 357 | "description": "Auto-refresh via the cookie auth OnValidatePrincipal event — runs on every authenticated request", |
358 | 358 | "code": "static async Task ValidatePrincipalWithRefresh(CookieValidatePrincipalContext ctx)\n{\n var expiresAt = ctx.Properties.GetTokenValue(\"expires_at\");\n if (expiresAt is null || !DateTimeOffset.TryParse(expiresAt, out var expiry)) return;\n if (expiry - DateTimeOffset.UtcNow > TimeSpan.FromSeconds(60)) return;\n\n var refreshToken = ctx.Properties.GetTokenValue(\"refresh_token\");\n if (refreshToken is null)\n {\n ctx.RejectPrincipal();\n return;\n }\n\n var httpFactory = ctx.HttpContext.RequestServices.GetRequiredService<IHttpClientFactory>();\n try\n {\n var tokens = await RefreshTokensAsync(httpFactory, refreshToken);\n if (tokens.IsError)\n {\n ctx.RejectPrincipal();\n return;\n }\n\n UpdateAuthProperties(ctx.Properties, tokens);\n ctx.ShouldRenew = true;\n }\n catch\n {\n ctx.RejectPrincipal();\n }\n}", |
359 | 359 | "file": "src/Program.cs", |
360 | 360 | "lang": "csharp", |
361 | | - "lines": "143-175" |
| 361 | + "lines": "144-176" |
362 | 362 | } |
363 | 363 | ], |
364 | 364 | "framework": "dotnet", |
|
0 commit comments