diff --git a/9.0/BlazorWebAppOidc/BlazorWebAppOidc/BlazorWebAppOidc.csproj b/9.0/BlazorWebAppOidc/BlazorWebAppOidc/BlazorWebAppOidc.csproj
index f015d5b2..be564cc5 100644
--- a/9.0/BlazorWebAppOidc/BlazorWebAppOidc/BlazorWebAppOidc.csproj
+++ b/9.0/BlazorWebAppOidc/BlazorWebAppOidc/BlazorWebAppOidc.csproj
@@ -8,6 +8,7 @@
+
diff --git a/9.0/BlazorWebAppOidc/BlazorWebAppOidc/CookieEvents.cs b/9.0/BlazorWebAppOidc/BlazorWebAppOidc/CookieEvents.cs
new file mode 100644
index 00000000..1af4889e
--- /dev/null
+++ b/9.0/BlazorWebAppOidc/BlazorWebAppOidc/CookieEvents.cs
@@ -0,0 +1,26 @@
+using Duende.AccessTokenManagement.OpenIdConnect;
+using Microsoft.AspNetCore.Authentication.Cookies;
+
+namespace BlazorWebAppOidc;
+
+///
+/// Automatically validates the token.
+/// See https://docs.duendesoftware.com/accesstokenmanagement/blazor-server/ for more information.
+///
+public class CookieEvents : CookieAuthenticationEvents
+{
+ private readonly IUserTokenStore _store;
+
+ public CookieEvents(IUserTokenStore store) => _store = store;
+
+ public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
+ {
+ var token = await _store.GetTokenAsync(context.Principal!);
+ if (!token.Succeeded)
+ {
+ context.RejectPrincipal();
+ }
+
+ await base.ValidatePrincipal(context);
+ }
+}
diff --git a/9.0/BlazorWebAppOidc/BlazorWebAppOidc/CookieOidcRefresher.cs b/9.0/BlazorWebAppOidc/BlazorWebAppOidc/CookieOidcRefresher.cs
deleted file mode 100644
index c832924e..00000000
--- a/9.0/BlazorWebAppOidc/BlazorWebAppOidc/CookieOidcRefresher.cs
+++ /dev/null
@@ -1,102 +0,0 @@
-using System.Globalization;
-using System.IdentityModel.Tokens.Jwt;
-using System.Security.Claims;
-using Microsoft.AspNetCore.Authentication;
-using Microsoft.AspNetCore.Authentication.Cookies;
-using Microsoft.AspNetCore.Authentication.OpenIdConnect;
-using Microsoft.Extensions.Options;
-using Microsoft.IdentityModel.JsonWebTokens;
-using Microsoft.IdentityModel.Protocols.OpenIdConnect;
-using Microsoft.IdentityModel.Tokens;
-
-namespace BlazorWebAppOidc;
-
-// https://github.com/dotnet/aspnetcore/issues/8175
-internal sealed class CookieOidcRefresher(IOptionsMonitor oidcOptionsMonitor)
-{
- private readonly OpenIdConnectProtocolValidator oidcTokenValidator = new()
- {
- // We no longer have the original nonce cookie which is deleted at the end of the authorization code flow having served its purpose.
- // Even if we had the nonce, it's likely expired. It's not intended for refresh requests. Otherwise, we'd use oidcOptions.ProtocolValidator.
- RequireNonce = false,
- };
-
- public async Task ValidateOrRefreshCookieAsync(CookieValidatePrincipalContext validateContext, string oidcScheme)
- {
- var accessTokenExpirationText = validateContext.Properties.GetTokenValue("expires_at");
- if (!DateTimeOffset.TryParse(accessTokenExpirationText, out var accessTokenExpiration))
- {
- return;
- }
-
- var oidcOptions = oidcOptionsMonitor.Get(oidcScheme);
- var now = oidcOptions.TimeProvider!.GetUtcNow();
- if (now + TimeSpan.FromMinutes(5) < accessTokenExpiration)
- {
- return;
- }
-
- var oidcConfiguration = await oidcOptions.ConfigurationManager!.GetConfigurationAsync(validateContext.HttpContext.RequestAborted);
- var tokenEndpoint = oidcConfiguration.TokenEndpoint ?? throw new InvalidOperationException("Cannot refresh cookie. TokenEndpoint missing!");
-
- using var refreshResponse = await oidcOptions.Backchannel.PostAsync(tokenEndpoint,
- new FormUrlEncodedContent(new Dictionary()
- {
- ["grant_type"] = "refresh_token",
- ["client_id"] = oidcOptions.ClientId,
- ["client_secret"] = oidcOptions.ClientSecret,
- ["scope"] = string.Join(" ", oidcOptions.Scope),
- ["refresh_token"] = validateContext.Properties.GetTokenValue("refresh_token"),
- }));
-
- if (!refreshResponse.IsSuccessStatusCode)
- {
- validateContext.RejectPrincipal();
- return;
- }
-
- var refreshJson = await refreshResponse.Content.ReadAsStringAsync();
- var message = new OpenIdConnectMessage(refreshJson);
-
- var validationParameters = oidcOptions.TokenValidationParameters.Clone();
- if (oidcOptions.ConfigurationManager is BaseConfigurationManager baseConfigurationManager)
- {
- validationParameters.ConfigurationManager = baseConfigurationManager;
- }
- else
- {
- validationParameters.ValidIssuer = oidcConfiguration.Issuer;
- validationParameters.IssuerSigningKeys = oidcConfiguration.SigningKeys;
- }
-
- var validationResult = await oidcOptions.TokenHandler.ValidateTokenAsync(message.IdToken, validationParameters);
-
- if (!validationResult.IsValid)
- {
- validateContext.RejectPrincipal();
- return;
- }
-
- var validatedIdToken = JwtSecurityTokenConverter.Convert(validationResult.SecurityToken as JsonWebToken);
- validatedIdToken.Payload["nonce"] = null;
- oidcTokenValidator.ValidateTokenResponse(new()
- {
- ProtocolMessage = message,
- ClientId = oidcOptions.ClientId,
- ValidatedIdToken = validatedIdToken,
- });
-
- validateContext.ShouldRenew = true;
- validateContext.ReplacePrincipal(new ClaimsPrincipal(validationResult.ClaimsIdentity));
-
- var expiresIn = int.Parse(message.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture);
- var expiresAt = now + TimeSpan.FromSeconds(expiresIn);
- validateContext.Properties.StoreTokens([
- new() { Name = "access_token", Value = message.AccessToken },
- new() { Name = "id_token", Value = message.IdToken },
- new() { Name = "refresh_token", Value = message.RefreshToken },
- new() { Name = "token_type", Value = message.TokenType },
- new() { Name = "expires_at", Value = expiresAt.ToString("o", CultureInfo.InvariantCulture) },
- ]);
- }
-}
diff --git a/9.0/BlazorWebAppOidc/BlazorWebAppOidc/CookieOidcServiceCollectionExtensions.cs b/9.0/BlazorWebAppOidc/BlazorWebAppOidc/CookieOidcServiceCollectionExtensions.cs
deleted file mode 100644
index acbac154..00000000
--- a/9.0/BlazorWebAppOidc/BlazorWebAppOidc/CookieOidcServiceCollectionExtensions.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using Microsoft.AspNetCore.Authentication.Cookies;
-using Microsoft.AspNetCore.Authentication.OpenIdConnect;
-using Microsoft.IdentityModel.Protocols.OpenIdConnect;
-using BlazorWebAppOidc;
-
-namespace Microsoft.Extensions.DependencyInjection;
-
-internal static partial class CookieOidcServiceCollectionExtensions
-{
- public static IServiceCollection ConfigureCookieOidc(this IServiceCollection services, string cookieScheme, string oidcScheme)
- {
- services.AddSingleton();
- services.AddOptions(cookieScheme).Configure((cookieOptions, refresher) =>
- {
- cookieOptions.Events.OnValidatePrincipal = context => refresher.ValidateOrRefreshCookieAsync(context, oidcScheme);
- });
- services.AddOptions(oidcScheme).Configure(oidcOptions =>
- {
- // Request a refresh_token.
- oidcOptions.Scope.Add(OpenIdConnectScope.OfflineAccess);
- // Store the refresh_token.
- oidcOptions.SaveTokens = true;
- });
- return services;
- }
-}
diff --git a/9.0/BlazorWebAppOidc/BlazorWebAppOidc/OidcEvents.cs b/9.0/BlazorWebAppOidc/BlazorWebAppOidc/OidcEvents.cs
new file mode 100644
index 00000000..f104fe7d
--- /dev/null
+++ b/9.0/BlazorWebAppOidc/BlazorWebAppOidc/OidcEvents.cs
@@ -0,0 +1,38 @@
+using Duende.AccessTokenManagement;
+using Duende.AccessTokenManagement.OpenIdConnect;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+
+namespace BlazorWebAppOidc;
+
+///
+/// Stores access token and refresh token in the server-side token store once a token is validated.
+/// See https://docs.duendesoftware.com/accesstokenmanagement/blazor-server/ for more information.
+///
+public class OidcEvents : OpenIdConnectEvents
+{
+ private readonly IUserTokenStore _store;
+
+ public OidcEvents(IUserTokenStore store) => _store = store;
+
+ public override async Task TokenValidated(TokenValidatedContext context)
+ {
+ var exp = DateTimeOffset.UtcNow.AddSeconds(double.Parse(context.TokenEndpointResponse!.ExpiresIn));
+
+ await _store.StoreTokenAsync(context.Principal!, new UserToken
+ {
+ AccessToken = AccessToken.Parse(context.TokenEndpointResponse.AccessToken),
+ AccessTokenType = AccessTokenType.Parse(context.TokenEndpointResponse.TokenType),
+ RefreshToken = RefreshToken.Parse(context.TokenEndpointResponse.RefreshToken),
+ Scope = !string.IsNullOrEmpty(context.TokenEndpointResponse.Scope)
+ ? Scope.Parse(context.TokenEndpointResponse.Scope)
+ : Scope.Parse(string.Join(" ", context.Options.Scope)),
+ ClientId = !string.IsNullOrEmpty(context.ProtocolMessage.ClientId)
+ ? ClientId.Parse(context.ProtocolMessage.ClientId)
+ : ClientId.Parse(context.Options.ClientId!),
+ IdentityToken = IdentityToken.Parse(context.TokenEndpointResponse.IdToken),
+ Expiration = exp,
+ });
+
+ await base.TokenValidated(context);
+ }
+}
diff --git a/9.0/BlazorWebAppOidc/BlazorWebAppOidc/Program.cs b/9.0/BlazorWebAppOidc/BlazorWebAppOidc/Program.cs
index 34ba7dd7..87c71b21 100644
--- a/9.0/BlazorWebAppOidc/BlazorWebAppOidc/Program.cs
+++ b/9.0/BlazorWebAppOidc/BlazorWebAppOidc/Program.cs
@@ -1,10 +1,11 @@
+using BlazorWebAppOidc;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
-using BlazorWebAppOidc;
using BlazorWebAppOidc.Client.Weather;
using BlazorWebAppOidc.Components;
using BlazorWebAppOidc.Weather;
+using Duende.AccessTokenManagement.OpenIdConnect;
const string MS_OIDC_SCHEME = "MicrosoftOidc";
@@ -20,9 +21,9 @@
// ........................................................................
// Pushed Authorization Requests (PAR) support. By default, the setting is
- // to use PAR if the identity provider's discovery document (usually found
- // at '.well-known/openid-configuration') advertises support for PAR. If
- // you wish to require PAR support for the app, you can assign
+ // to use PAR if the identity provider's discovery document (usually found
+ // at '.well-known/openid-configuration') advertises support for PAR. If
+ // you wish to require PAR support for the app, you can assign
// 'PushedAuthorizationBehavior.Require' to 'PushedAuthorizationBehavior'.
//
// Note that PAR isn't supported by Microsoft Entra, and there are no plans
@@ -32,16 +33,16 @@
// ........................................................................
// ........................................................................
- // The OIDC handler must use a sign-in scheme capable of persisting
+ // The OIDC handler must use a sign-in scheme capable of persisting
// user credentials across requests.
oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// ........................................................................
// ........................................................................
- // The "openid" and "profile" scopes are required for the OIDC handler
- // and included by default. You should enable these scopes here if scopes
- // are provided by "Authentication:Schemes:MicrosoftOidc:Scope"
+ // The "openid" and "profile" scopes are required for the OIDC handler
+ // and included by default. You should enable these scopes here if scopes
+ // are provided by "Authentication:Schemes:MicrosoftOidc:Scope"
// configuration because configuration may overwrite the scopes collection.
//oidcOptions.Scope.Add(OpenIdConnectScope.OpenIdProfile);
@@ -49,7 +50,7 @@
// ........................................................................
// The "Weather.Get" scope for accessing the external web API for weather
- // data. The following example is based on using Microsoft Entra ID in
+ // data. The following example is based on using Microsoft Entra ID in
// an ME-ID tenant domain (the {APP ID URI} placeholder is found in
// the Entra or Azure portal where the web API is exposed). For any other
// identity provider, use the appropriate scope.
@@ -58,8 +59,8 @@
// ........................................................................
// ........................................................................
- // The following paths must match the redirect and post logout redirect
- // paths configured when registering the application with the OIDC provider.
+ // The following paths must match the redirect and post logout redirect
+ // paths configured when registering the application with the OIDC provider.
// The default values are "/signin-oidc" and "/signout-callback-oidc".
//oidcOptions.CallbackPath = new PathString("/signin-oidc");
@@ -67,7 +68,7 @@
// ........................................................................
// ........................................................................
- // The RemoteSignOutPath is the "Front-channel logout URL" for remote single
+ // The RemoteSignOutPath is the "Front-channel logout URL" for remote single
// sign-out. The default value is "/signout-oidc".
//oidcOptions.RemoteSignOutPath = new PathString("/signout-oidc");
@@ -75,12 +76,12 @@
// ........................................................................
// The following example Authority is configured for Microsoft Entra ID
- // and a single-tenant application registration. Set the {TENANT ID}
- // placeholder to the Tenant ID. The "common" Authority
- // https://login.microsoftonline.com/common/v2.0/ should be used
- // for multi-tenant apps. You can also use the "common" Authority for
- // single-tenant apps, but it requires a custom IssuerValidator as shown
- // in the comments below.
+ // and a single-tenant application registration. Set the {TENANT ID}
+ // placeholder to the Tenant ID. The "common" Authority
+ // https://login.microsoftonline.com/common/v2.0/ should be used
+ // for multi-tenant apps. You can also use the "common" Authority for
+ // single-tenant apps, but it requires a custom IssuerValidator as shown
+ // in the comments below.
oidcOptions.Authority = "https://login.microsoftonline.com/{TENANT ID}/v2.0/";
// ........................................................................
@@ -93,20 +94,20 @@
// ........................................................................
// ........................................................................
- // Setting ResponseType to "code" configures the OIDC handler to use
+ // Setting ResponseType to "code" configures the OIDC handler to use
// authorization code flow. Implicit grants and hybrid flows are unnecessary
- // in this mode. In a Microsoft Entra ID app registration, you don't need to
- // select either box for the authorization endpoint to return access tokens
- // or ID tokens. The OIDC handler automatically requests the appropriate
+ // in this mode. In a Microsoft Entra ID app registration, you don't need to
+ // select either box for the authorization endpoint to return access tokens
+ // or ID tokens. The OIDC handler automatically requests the appropriate
// tokens using the code returned from the authorization endpoint.
oidcOptions.ResponseType = OpenIdConnectResponseType.Code;
// ........................................................................
// ........................................................................
- // Set MapInboundClaims to "false" to obtain the original claim types from
- // the token. Many OIDC servers use "name" and "role"/"roles" rather than
- // the SOAP/WS-Fed defaults in ClaimTypes. Adjust these values if your
+ // Set MapInboundClaims to "false" to obtain the original claim types from
+ // the token. Many OIDC servers use "name" and "role"/"roles" rather than
+ // the SOAP/WS-Fed defaults in ClaimTypes. Adjust these values if your
// identity provider uses different claim types.
oidcOptions.MapInboundClaims = false;
@@ -116,7 +117,7 @@
// ........................................................................
// Many OIDC providers work with the default issuer validator, but the
- // configuration must account for the issuer parameterized with "{TENANT ID}"
+ // configuration must account for the issuer parameterized with "{TENANT ID}"
// returned by the "common" endpoint's /.well-known/openid-configuration
// For more information, see
// https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/issues/1731
@@ -126,7 +127,7 @@
// ........................................................................
// ........................................................................
- // OIDC connect options set later via ConfigureCookieOidc
+ // OIDC connect options to handle token refresh
//
// (1) The "offline_access" scope is required for the refresh token.
//
@@ -135,21 +136,29 @@
// use the refresh token to obtain a new access token on access token
// expiration.
// ........................................................................
- })
- .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
+ oidcOptions.Scope.Add(OpenIdConnectScope.OfflineAccess);
+ oidcOptions.SaveTokens = true;
-// ConfigureCookieOidc attaches a cookie OnValidatePrincipal callback to get
-// a new access token when the current one expires, and reissue a cookie with the
-// new access token saved inside. If the refresh fails, the user will be signed
-// out. OIDC connect options are set for saving tokens and the offline access
-// scope.
-builder.Services.ConfigureCookieOidc(CookieAuthenticationDefaults.AuthenticationScheme, MS_OIDC_SCHEME);
+ // ........................................................................
+ // Registers OidcEvents as the class that handles events raised by the OIDC
+ // handler.
+ // ........................................................................
+ oidcOptions.EventsType = typeof(OidcEvents);
+ })
+ .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
+ {
+ // ........................................................................
+ // Registers CookieEvents as the class that handles events raised by the
+ // Cookie handler.
+ // ........................................................................
+ options.EventsType = typeof(CookieEvents);
+ });
builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();
-// Remove or set 'SerializeAllClaims' to 'false' if you only want to
+// Remove or set 'SerializeAllClaims' to 'false' if you only want to
// serialize name and role claims for CSR.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
@@ -160,12 +169,22 @@
builder.Services.AddHttpContextAccessor();
-builder.Services.AddScoped();
+// Add event types to customize authentication handlers
+builder.Services.AddTransient();
+builder.Services.AddTransient();
+
+// Add Duende Access Token Management
+builder.Services.AddOpenIdConnectAccessTokenManagement()
+ .AddBlazorServerAccessTokenManagement();
+// Registers HTTP client that uses the managed user access token. It fetches
+// a new access token when the current one expires, and reissue a cookie with the
+// new access token saved inside.OIDC connect options are set for saving tokens and
+// the offline access scope.
builder.Services.AddHttpClient("ExternalApi",
- client => client.BaseAddress = new Uri(builder.Configuration["ExternalApiUri"] ??
+ client => client.BaseAddress = new Uri(builder.Configuration["ExternalApiUri"] ??
throw new Exception("Missing base address!")))
- .AddHttpMessageHandler();
+ .AddUserAccessTokenHandler();
var app = builder.Build();
diff --git a/9.0/BlazorWebAppOidc/BlazorWebAppOidc/ServerSideTokenStore.cs b/9.0/BlazorWebAppOidc/BlazorWebAppOidc/ServerSideTokenStore.cs
new file mode 100644
index 00000000..c27c5c46
--- /dev/null
+++ b/9.0/BlazorWebAppOidc/BlazorWebAppOidc/ServerSideTokenStore.cs
@@ -0,0 +1,47 @@
+using System.Collections.Concurrent;
+using System.Security.Claims;
+using Duende.AccessTokenManagement;
+using Duende.AccessTokenManagement.OpenIdConnect;
+
+namespace BlazorWebAppOidc;
+
+///
+/// Simple implementation of a server-side token store.
+/// See https://docs.duendesoftware.com/accesstokenmanagement/blazor-server/ for more information.
+///
+public class ServerSideTokenStore : IUserTokenStore
+{
+ private static readonly ConcurrentDictionary _tokens = new();
+
+ public Task> GetTokenAsync(ClaimsPrincipal user, UserTokenRequestParameters? parameters = null,
+ CancellationToken cancellationToken = default)
+ {
+ var sub = user.FindFirst("sub")?.Value ?? throw new InvalidOperationException("no sub claim");
+
+ if (_tokens.TryGetValue(sub, out var value))
+ {
+ return Task.FromResult(TokenResult.Success(value));
+ }
+
+ return Task.FromResult((TokenResult)TokenResult.Failure("not found"));
+ }
+
+ public Task StoreTokenAsync(ClaimsPrincipal user, UserToken token, UserTokenRequestParameters? parameters = null, CancellationToken ct = default)
+ {
+ var sub = user.FindFirst("sub")?.Value ?? throw new InvalidOperationException("no sub claim");
+ _tokens[sub] = new TokenForParameters(token,
+ token.RefreshToken == null
+ ? null
+ : new UserRefreshToken(token.RefreshToken.Value, token.DPoPJsonWebKey));
+
+ return Task.CompletedTask;
+ }
+
+ public Task ClearTokenAsync(ClaimsPrincipal user, UserTokenRequestParameters? parameters = null, CancellationToken ct = default)
+ {
+ var sub = user.FindFirst("sub")?.Value ?? throw new InvalidOperationException("no sub claim");
+
+ _tokens.TryRemove(sub, out _);
+ return Task.CompletedTask;
+ }
+}
diff --git a/9.0/BlazorWebAppOidc/BlazorWebAppOidc/TokenHandler.cs b/9.0/BlazorWebAppOidc/BlazorWebAppOidc/TokenHandler.cs
deleted file mode 100644
index 3a486164..00000000
--- a/9.0/BlazorWebAppOidc/BlazorWebAppOidc/TokenHandler.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using System.Net.Http.Headers;
-using Microsoft.AspNetCore.Authentication;
-
-namespace BlazorWebAppOidc;
-
-public class TokenHandler(IHttpContextAccessor httpContextAccessor) :
- DelegatingHandler
-{
- protected override async Task SendAsync(
- HttpRequestMessage request, CancellationToken cancellationToken)
- {
- if (httpContextAccessor.HttpContext is null)
- {
- throw new Exception("HttpContext not available");
- }
-
- var accessToken = await httpContextAccessor.HttpContext
- .GetTokenAsync("access_token");
-
- request.Headers.Authorization =
- new AuthenticationHeaderValue("Bearer", accessToken);
-
- return await base.SendAsync(request, cancellationToken);
- }
-}
diff --git a/9.0/BlazorWebAppOidc/MinimalApiJwt/Program.cs b/9.0/BlazorWebAppOidc/MinimalApiJwt/Program.cs
index 7cb7b0d7..4f302b6c 100644
--- a/9.0/BlazorWebAppOidc/MinimalApiJwt/Program.cs
+++ b/9.0/BlazorWebAppOidc/MinimalApiJwt/Program.cs
@@ -5,7 +5,7 @@
{
// {TENANT ID} is the directory (tenant) ID.
//
- // Authority format {AUTHORITY} matches the issurer (`iss`) of the JWT returned by the identity provider.
+ // Authority format {AUTHORITY} matches the issuer (`iss`) of the JWT returned by the identity provider.
//
// Authority format {AUTHORITY} for ME-ID tenant type: https://sts.windows.net/{TENANT ID}/
// Authority format {AUTHORITY} for B2C tenant type: https://login.microsoftonline.com/{TENANT ID}/v2.0/
@@ -13,9 +13,9 @@
jwtOptions.Authority = "{AUTHORITY}";
//
// The following should match just the path of the Application ID URI configured when adding the "Weather.Get" scope
- // under "Expose an API" in the Azure or Entra portal. {CLIENT ID} is the application (client) ID of this
+ // under "Expose an API" in the Azure or Entra portal. {CLIENT ID} is the application (client) ID of this
// app's registration in the Azure portal.
- //
+ //
// Audience format {AUDIENCE} for ME-ID tenant type: api://{CLIENT ID}
// Audience format {AUDIENCE} for B2C tenant type: https://{DIRECTORY NAME}.onmicrosoft.com/{CLIENT ID}
//
@@ -23,7 +23,7 @@
});
builder.Services.AddAuthorization();
-// Add OpenApi
+// Add OpenApi
builder.Services.AddOpenApi();
var app = builder.Build();
diff --git a/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/BlazorWebAppOidc.csproj b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/BlazorWebAppOidc.csproj
index 81d24126..ceec0f58 100644
--- a/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/BlazorWebAppOidc.csproj
+++ b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/BlazorWebAppOidc.csproj
@@ -9,6 +9,7 @@
+
diff --git a/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/CookieEvents.cs b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/CookieEvents.cs
new file mode 100644
index 00000000..1af4889e
--- /dev/null
+++ b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/CookieEvents.cs
@@ -0,0 +1,26 @@
+using Duende.AccessTokenManagement.OpenIdConnect;
+using Microsoft.AspNetCore.Authentication.Cookies;
+
+namespace BlazorWebAppOidc;
+
+///
+/// Automatically validates the token.
+/// See https://docs.duendesoftware.com/accesstokenmanagement/blazor-server/ for more information.
+///
+public class CookieEvents : CookieAuthenticationEvents
+{
+ private readonly IUserTokenStore _store;
+
+ public CookieEvents(IUserTokenStore store) => _store = store;
+
+ public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
+ {
+ var token = await _store.GetTokenAsync(context.Principal!);
+ if (!token.Succeeded)
+ {
+ context.RejectPrincipal();
+ }
+
+ await base.ValidatePrincipal(context);
+ }
+}
diff --git a/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/CookieOidcRefresher.cs b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/CookieOidcRefresher.cs
deleted file mode 100644
index c832924e..00000000
--- a/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/CookieOidcRefresher.cs
+++ /dev/null
@@ -1,102 +0,0 @@
-using System.Globalization;
-using System.IdentityModel.Tokens.Jwt;
-using System.Security.Claims;
-using Microsoft.AspNetCore.Authentication;
-using Microsoft.AspNetCore.Authentication.Cookies;
-using Microsoft.AspNetCore.Authentication.OpenIdConnect;
-using Microsoft.Extensions.Options;
-using Microsoft.IdentityModel.JsonWebTokens;
-using Microsoft.IdentityModel.Protocols.OpenIdConnect;
-using Microsoft.IdentityModel.Tokens;
-
-namespace BlazorWebAppOidc;
-
-// https://github.com/dotnet/aspnetcore/issues/8175
-internal sealed class CookieOidcRefresher(IOptionsMonitor oidcOptionsMonitor)
-{
- private readonly OpenIdConnectProtocolValidator oidcTokenValidator = new()
- {
- // We no longer have the original nonce cookie which is deleted at the end of the authorization code flow having served its purpose.
- // Even if we had the nonce, it's likely expired. It's not intended for refresh requests. Otherwise, we'd use oidcOptions.ProtocolValidator.
- RequireNonce = false,
- };
-
- public async Task ValidateOrRefreshCookieAsync(CookieValidatePrincipalContext validateContext, string oidcScheme)
- {
- var accessTokenExpirationText = validateContext.Properties.GetTokenValue("expires_at");
- if (!DateTimeOffset.TryParse(accessTokenExpirationText, out var accessTokenExpiration))
- {
- return;
- }
-
- var oidcOptions = oidcOptionsMonitor.Get(oidcScheme);
- var now = oidcOptions.TimeProvider!.GetUtcNow();
- if (now + TimeSpan.FromMinutes(5) < accessTokenExpiration)
- {
- return;
- }
-
- var oidcConfiguration = await oidcOptions.ConfigurationManager!.GetConfigurationAsync(validateContext.HttpContext.RequestAborted);
- var tokenEndpoint = oidcConfiguration.TokenEndpoint ?? throw new InvalidOperationException("Cannot refresh cookie. TokenEndpoint missing!");
-
- using var refreshResponse = await oidcOptions.Backchannel.PostAsync(tokenEndpoint,
- new FormUrlEncodedContent(new Dictionary()
- {
- ["grant_type"] = "refresh_token",
- ["client_id"] = oidcOptions.ClientId,
- ["client_secret"] = oidcOptions.ClientSecret,
- ["scope"] = string.Join(" ", oidcOptions.Scope),
- ["refresh_token"] = validateContext.Properties.GetTokenValue("refresh_token"),
- }));
-
- if (!refreshResponse.IsSuccessStatusCode)
- {
- validateContext.RejectPrincipal();
- return;
- }
-
- var refreshJson = await refreshResponse.Content.ReadAsStringAsync();
- var message = new OpenIdConnectMessage(refreshJson);
-
- var validationParameters = oidcOptions.TokenValidationParameters.Clone();
- if (oidcOptions.ConfigurationManager is BaseConfigurationManager baseConfigurationManager)
- {
- validationParameters.ConfigurationManager = baseConfigurationManager;
- }
- else
- {
- validationParameters.ValidIssuer = oidcConfiguration.Issuer;
- validationParameters.IssuerSigningKeys = oidcConfiguration.SigningKeys;
- }
-
- var validationResult = await oidcOptions.TokenHandler.ValidateTokenAsync(message.IdToken, validationParameters);
-
- if (!validationResult.IsValid)
- {
- validateContext.RejectPrincipal();
- return;
- }
-
- var validatedIdToken = JwtSecurityTokenConverter.Convert(validationResult.SecurityToken as JsonWebToken);
- validatedIdToken.Payload["nonce"] = null;
- oidcTokenValidator.ValidateTokenResponse(new()
- {
- ProtocolMessage = message,
- ClientId = oidcOptions.ClientId,
- ValidatedIdToken = validatedIdToken,
- });
-
- validateContext.ShouldRenew = true;
- validateContext.ReplacePrincipal(new ClaimsPrincipal(validationResult.ClaimsIdentity));
-
- var expiresIn = int.Parse(message.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture);
- var expiresAt = now + TimeSpan.FromSeconds(expiresIn);
- validateContext.Properties.StoreTokens([
- new() { Name = "access_token", Value = message.AccessToken },
- new() { Name = "id_token", Value = message.IdToken },
- new() { Name = "refresh_token", Value = message.RefreshToken },
- new() { Name = "token_type", Value = message.TokenType },
- new() { Name = "expires_at", Value = expiresAt.ToString("o", CultureInfo.InvariantCulture) },
- ]);
- }
-}
diff --git a/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/CookieOidcServiceCollectionExtensions.cs b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/CookieOidcServiceCollectionExtensions.cs
deleted file mode 100644
index acbac154..00000000
--- a/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/CookieOidcServiceCollectionExtensions.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using Microsoft.AspNetCore.Authentication.Cookies;
-using Microsoft.AspNetCore.Authentication.OpenIdConnect;
-using Microsoft.IdentityModel.Protocols.OpenIdConnect;
-using BlazorWebAppOidc;
-
-namespace Microsoft.Extensions.DependencyInjection;
-
-internal static partial class CookieOidcServiceCollectionExtensions
-{
- public static IServiceCollection ConfigureCookieOidc(this IServiceCollection services, string cookieScheme, string oidcScheme)
- {
- services.AddSingleton();
- services.AddOptions(cookieScheme).Configure((cookieOptions, refresher) =>
- {
- cookieOptions.Events.OnValidatePrincipal = context => refresher.ValidateOrRefreshCookieAsync(context, oidcScheme);
- });
- services.AddOptions(oidcScheme).Configure(oidcOptions =>
- {
- // Request a refresh_token.
- oidcOptions.Scope.Add(OpenIdConnectScope.OfflineAccess);
- // Store the refresh_token.
- oidcOptions.SaveTokens = true;
- });
- return services;
- }
-}
diff --git a/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/OidcEvents.cs b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/OidcEvents.cs
new file mode 100644
index 00000000..f104fe7d
--- /dev/null
+++ b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/OidcEvents.cs
@@ -0,0 +1,38 @@
+using Duende.AccessTokenManagement;
+using Duende.AccessTokenManagement.OpenIdConnect;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+
+namespace BlazorWebAppOidc;
+
+///
+/// Stores access token and refresh token in the server-side token store once a token is validated.
+/// See https://docs.duendesoftware.com/accesstokenmanagement/blazor-server/ for more information.
+///
+public class OidcEvents : OpenIdConnectEvents
+{
+ private readonly IUserTokenStore _store;
+
+ public OidcEvents(IUserTokenStore store) => _store = store;
+
+ public override async Task TokenValidated(TokenValidatedContext context)
+ {
+ var exp = DateTimeOffset.UtcNow.AddSeconds(double.Parse(context.TokenEndpointResponse!.ExpiresIn));
+
+ await _store.StoreTokenAsync(context.Principal!, new UserToken
+ {
+ AccessToken = AccessToken.Parse(context.TokenEndpointResponse.AccessToken),
+ AccessTokenType = AccessTokenType.Parse(context.TokenEndpointResponse.TokenType),
+ RefreshToken = RefreshToken.Parse(context.TokenEndpointResponse.RefreshToken),
+ Scope = !string.IsNullOrEmpty(context.TokenEndpointResponse.Scope)
+ ? Scope.Parse(context.TokenEndpointResponse.Scope)
+ : Scope.Parse(string.Join(" ", context.Options.Scope)),
+ ClientId = !string.IsNullOrEmpty(context.ProtocolMessage.ClientId)
+ ? ClientId.Parse(context.ProtocolMessage.ClientId)
+ : ClientId.Parse(context.Options.ClientId!),
+ IdentityToken = IdentityToken.Parse(context.TokenEndpointResponse.IdToken),
+ Expiration = exp,
+ });
+
+ await base.TokenValidated(context);
+ }
+}
diff --git a/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/Program.cs b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/Program.cs
index 2ae942d2..7a0103d6 100644
--- a/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/Program.cs
+++ b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/Program.cs
@@ -6,6 +6,7 @@
using BlazorWebAppOidc;
using BlazorWebAppOidc.Client.Weather;
using BlazorWebAppOidc.Components;
+using Duende.AccessTokenManagement.OpenIdConnect;
const string MS_OIDC_SCHEME = "MicrosoftOidc";
@@ -24,9 +25,9 @@
// ........................................................................
// Pushed Authorization Requests (PAR) support. By default, the setting is
- // to use PAR if the identity provider's discovery document (usually found
- // at '.well-known/openid-configuration') advertises support for PAR. If
- // you wish to require PAR support for the app, you can assign
+ // to use PAR if the identity provider's discovery document (usually found
+ // at '.well-known/openid-configuration') advertises support for PAR. If
+ // you wish to require PAR support for the app, you can assign
// 'PushedAuthorizationBehavior.Require' to 'PushedAuthorizationBehavior'.
//
// Note that PAR isn't supported by Microsoft Entra, and there are no plans
@@ -36,24 +37,24 @@
// ........................................................................
// ........................................................................
- // The OIDC handler must use a sign-in scheme capable of persisting
+ // The OIDC handler must use a sign-in scheme capable of persisting
// user credentials across requests.
oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// ........................................................................
// ........................................................................
- // The "openid" and "profile" scopes are required for the OIDC handler
- // and included by default. You should enable these scopes here if scopes
- // are provided by "Authentication:Schemes:MicrosoftOidc:Scope"
+ // The "openid" and "profile" scopes are required for the OIDC handler
+ // and included by default. You should enable these scopes here if scopes
+ // are provided by "Authentication:Schemes:MicrosoftOidc:Scope"
// configuration because configuration may overwrite the scopes collection.
//oidcOptions.Scope.Add(OpenIdConnectScope.OpenIdProfile);
// ........................................................................
// ........................................................................
- // The following paths must match the redirect and post logout redirect
- // paths configured when registering the application with the OIDC provider.
+ // The following paths must match the redirect and post logout redirect
+ // paths configured when registering the application with the OIDC provider.
// The default values are "/signin-oidc" and "/signout-callback-oidc".
//oidcOptions.CallbackPath = new PathString("/signin-oidc");
@@ -61,7 +62,7 @@
// ........................................................................
// ........................................................................
- // The RemoteSignOutPath is the "Front-channel logout URL" for remote single
+ // The RemoteSignOutPath is the "Front-channel logout URL" for remote single
// sign-out. The default value is "/signout-oidc".
//oidcOptions.RemoteSignOutPath = new PathString("/signout-oidc");
@@ -79,12 +80,12 @@
// ........................................................................
// The following example Authority is configured for Microsoft Entra ID
- // and a single-tenant application registration. Set the {TENANT ID}
- // placeholder to the Tenant ID. The "common" Authority
- // https://login.microsoftonline.com/common/v2.0/ should be used
- // for multi-tenant apps. You can also use the "common" Authority for
- // single-tenant apps, but it requires a custom IssuerValidator as shown
- // in the comments below.
+ // and a single-tenant application registration. Set the {TENANT ID}
+ // placeholder to the Tenant ID. The "common" Authority
+ // https://login.microsoftonline.com/common/v2.0/ should be used
+ // for multi-tenant apps. You can also use the "common" Authority for
+ // single-tenant apps, but it requires a custom IssuerValidator as shown
+ // in the comments below.
oidcOptions.Authority = "https://login.microsoftonline.com/{TENANT ID}/v2.0/";
// ........................................................................
@@ -97,20 +98,20 @@
// ........................................................................
// ........................................................................
- // Setting ResponseType to "code" configures the OIDC handler to use
+ // Setting ResponseType to "code" configures the OIDC handler to use
// authorization code flow. Implicit grants and hybrid flows are unnecessary
- // in this mode. In a Microsoft Entra ID app registration, you don't need to
- // select either box for the authorization endpoint to return access tokens
- // or ID tokens. The OIDC handler automatically requests the appropriate
+ // in this mode. In a Microsoft Entra ID app registration, you don't need to
+ // select either box for the authorization endpoint to return access tokens
+ // or ID tokens. The OIDC handler automatically requests the appropriate
// tokens using the code returned from the authorization endpoint.
oidcOptions.ResponseType = OpenIdConnectResponseType.Code;
// ........................................................................
// ........................................................................
- // // Set MapInboundClaims to "false" to obtain the original claim types from
- // the token. Many OIDC servers use "name" and "role"/"roles" rather than
- // the SOAP/WS-Fed defaults in ClaimTypes. Adjust these values if your
+ // Set MapInboundClaims to "false" to obtain the original claim types from
+ // the token. Many OIDC servers use "name" and "role"/"roles" rather than
+ // the SOAP/WS-Fed defaults in ClaimTypes. Adjust these values if your
// identity provider uses different claim types.
oidcOptions.MapInboundClaims = false;
@@ -120,7 +121,7 @@
// ........................................................................
// Many OIDC providers work with the default issuer validator, but the
- // configuration must account for the issuer parameterized with "{TENANT ID}"
+ // configuration must account for the issuer parameterized with "{TENANT ID}"
// returned by the "common" endpoint's /.well-known/openid-configuration
// For more information, see
// https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/issues/1731
@@ -130,31 +131,38 @@
// ........................................................................
// ........................................................................
- // OIDC connect options set later via ConfigureCookieOidc
+ // OIDC connect options to handle token refresh
//
// (1) The "offline_access" scope is required for the refresh token.
//
// (2) SaveTokens is set to true, which saves the access and refresh tokens
// in the cookie, so the app can authenticate requests for weather data and
- // cookie, so the app can authenticate requests for weather data and
// use the refresh token to obtain a new access token on access token
// expiration.
// ........................................................................
- })
- .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
+ oidcOptions.Scope.Add(OpenIdConnectScope.OfflineAccess);
+ oidcOptions.SaveTokens = true;
-// ConfigureCookieOidc attaches a cookie OnValidatePrincipal callback to get
-// a new access token when the current one expires, and reissue a cookie with the
-// new access token saved inside. If the refresh fails, the user will be signed
-// out. OIDC connect options are set for saving tokens and the offline access
-// scope.
-builder.Services.ConfigureCookieOidc(CookieAuthenticationDefaults.AuthenticationScheme, MS_OIDC_SCHEME);
+ // ........................................................................
+ // Registers OidcEvents as the class that handles events raised by the OIDC
+ // handler.
+ // ........................................................................
+ oidcOptions.EventsType = typeof(OidcEvents);
+ })
+ .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
+ {
+ // ........................................................................
+ // Registers CookieEvents as the class that handles events raised by the
+ // Cookie handler.
+ // ........................................................................
+ options.EventsType = typeof(CookieEvents);
+ });
builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();
-// Remove or set 'SerializeAllClaims' to 'false' if you only want to
+// Remove or set 'SerializeAllClaims' to 'false' if you only want to
// serialize name and role claims for CSR.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
@@ -163,10 +171,26 @@
builder.Services.AddHttpForwarderWithServiceDiscovery();
builder.Services.AddHttpContextAccessor();
-builder.Services.AddHttpClient(httpClient =>
-{
- httpClient.BaseAddress = new("https://weatherapi");
-});
+
+
+// Add event types to customize authentication handlers
+builder.Services.AddTransient();
+builder.Services.AddTransient();
+
+// Add Duende Access Token Management
+builder.Services.AddOpenIdConnectAccessTokenManagement()
+ .AddBlazorServerAccessTokenManagement();
+
+// Registers HTTP client that uses the managed user access token. It fetches
+// a new access token when the current one expires, and reissue a cookie with the
+// new access token saved inside.OIDC connect options are set for saving tokens and
+// the offline access scope.
+builder.Services
+ .AddHttpClient(httpClient =>
+ {
+ httpClient.BaseAddress = new("https://weatherapi");
+ })
+ .AddUserAccessTokenHandler();
var app = builder.Build();
diff --git a/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/ServerSideTokenStore.cs b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/ServerSideTokenStore.cs
new file mode 100644
index 00000000..c27c5c46
--- /dev/null
+++ b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/ServerSideTokenStore.cs
@@ -0,0 +1,47 @@
+using System.Collections.Concurrent;
+using System.Security.Claims;
+using Duende.AccessTokenManagement;
+using Duende.AccessTokenManagement.OpenIdConnect;
+
+namespace BlazorWebAppOidc;
+
+///
+/// Simple implementation of a server-side token store.
+/// See https://docs.duendesoftware.com/accesstokenmanagement/blazor-server/ for more information.
+///
+public class ServerSideTokenStore : IUserTokenStore
+{
+ private static readonly ConcurrentDictionary _tokens = new();
+
+ public Task> GetTokenAsync(ClaimsPrincipal user, UserTokenRequestParameters? parameters = null,
+ CancellationToken cancellationToken = default)
+ {
+ var sub = user.FindFirst("sub")?.Value ?? throw new InvalidOperationException("no sub claim");
+
+ if (_tokens.TryGetValue(sub, out var value))
+ {
+ return Task.FromResult(TokenResult.Success(value));
+ }
+
+ return Task.FromResult((TokenResult)TokenResult.Failure("not found"));
+ }
+
+ public Task StoreTokenAsync(ClaimsPrincipal user, UserToken token, UserTokenRequestParameters? parameters = null, CancellationToken ct = default)
+ {
+ var sub = user.FindFirst("sub")?.Value ?? throw new InvalidOperationException("no sub claim");
+ _tokens[sub] = new TokenForParameters(token,
+ token.RefreshToken == null
+ ? null
+ : new UserRefreshToken(token.RefreshToken.Value, token.DPoPJsonWebKey));
+
+ return Task.CompletedTask;
+ }
+
+ public Task ClearTokenAsync(ClaimsPrincipal user, UserTokenRequestParameters? parameters = null, CancellationToken ct = default)
+ {
+ var sub = user.FindFirst("sub")?.Value ?? throw new InvalidOperationException("no sub claim");
+
+ _tokens.TryRemove(sub, out _);
+ return Task.CompletedTask;
+ }
+}
diff --git a/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/ServerWeatherForecaster.cs b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/ServerWeatherForecaster.cs
index 0ebd5b97..af6b4b61 100644
--- a/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/ServerWeatherForecaster.cs
+++ b/9.0/BlazorWebAppOidcBff/BlazorWebAppOidc/ServerWeatherForecaster.cs
@@ -7,12 +7,7 @@ internal sealed class ServerWeatherForecaster(HttpClient httpClient, IHttpContex
{
public async Task> GetWeatherForecastAsync()
{
- var httpContext = httpContextAccessor.HttpContext ??
- throw new InvalidOperationException("No HttpContext available from the IHttpContextAccessor!");
- var accessToken = await httpContext.GetTokenAsync("access_token") ??
- throw new InvalidOperationException("No access_token was saved");
using var request = new HttpRequestMessage(HttpMethod.Get, "/weather-forecast");
- request.Headers.Authorization = new("Bearer", accessToken);
using var response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
diff --git a/9.0/BlazorWebAppOidcBff/MinimalApiJwt/Program.cs b/9.0/BlazorWebAppOidcBff/MinimalApiJwt/Program.cs
index a453aac9..310d6bc1 100644
--- a/9.0/BlazorWebAppOidcBff/MinimalApiJwt/Program.cs
+++ b/9.0/BlazorWebAppOidcBff/MinimalApiJwt/Program.cs
@@ -8,7 +8,7 @@
{
// {TENANT ID} is the directory (tenant) ID.
//
- // Authority format {AUTHORITY} matches the issurer (`iss`) of the JWT returned by the identity provider.
+ // Authority format {AUTHORITY} matches the issuer (`iss`) of the JWT returned by the identity provider.
//
// Authority format {AUTHORITY} for ME-ID tenant type: https://sts.windows.net/{TENANT ID}/
// Authority format {AUTHORITY} for B2C tenant type: https://login.microsoftonline.com/{TENANT ID}/v2.0/
@@ -16,9 +16,9 @@
jwtOptions.Authority = "{AUTHORITY}";
//
// The following should match just the path of the Application ID URI configured when adding the "Weather.Get" scope
- // under "Expose an API" in the Azure or Entra portal. {CLIENT ID} is the application (client) ID of this
+ // under "Expose an API" in the Azure or Entra portal. {CLIENT ID} is the application (client) ID of this
// app's registration in the Azure portal.
- //
+ //
// Audience format {AUDIENCE} for ME-ID tenant type: api://{CLIENT ID}
// Audience format {AUDIENCE} for B2C tenant type: https://{DIRECTORY NAME}.onmicrosoft.com/{CLIENT ID}
//
@@ -26,7 +26,7 @@
});
builder.Services.AddAuthorization();
-// Add OpenApi
+// Add OpenApi
builder.Services.AddOpenApi();
var app = builder.Build();
diff --git a/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/BlazorWebAppOidcServer.csproj b/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/BlazorWebAppOidcServer.csproj
index 6b3982d6..f001f8d9 100644
--- a/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/BlazorWebAppOidcServer.csproj
+++ b/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/BlazorWebAppOidcServer.csproj
@@ -7,6 +7,7 @@
+
diff --git a/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/CookieOidcRefresher.cs b/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/CookieOidcRefresher.cs
deleted file mode 100644
index 3c25183f..00000000
--- a/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/CookieOidcRefresher.cs
+++ /dev/null
@@ -1,102 +0,0 @@
-using Microsoft.AspNetCore.Authentication;
-using Microsoft.AspNetCore.Authentication.Cookies;
-using Microsoft.AspNetCore.Authentication.OpenIdConnect;
-using Microsoft.Extensions.Options;
-using Microsoft.IdentityModel.JsonWebTokens;
-using Microsoft.IdentityModel.Protocols.OpenIdConnect;
-using Microsoft.IdentityModel.Tokens;
-using System.Globalization;
-using System.IdentityModel.Tokens.Jwt;
-using System.Security.Claims;
-
-namespace BlazorWebAppOidcServer;
-
-// https://github.com/dotnet/aspnetcore/issues/8175
-internal sealed class CookieOidcRefresher(IOptionsMonitor oidcOptionsMonitor)
-{
- private readonly OpenIdConnectProtocolValidator oidcTokenValidator = new()
- {
- // We no longer have the original nonce cookie which is deleted at the end of the authorization code flow having served its purpose.
- // Even if we had the nonce, it's likely expired. It's not intended for refresh requests. Otherwise, we'd use oidcOptions.ProtocolValidator.
- RequireNonce = false,
- };
-
- public async Task ValidateOrRefreshCookieAsync(CookieValidatePrincipalContext validateContext, string oidcScheme)
- {
- var accessTokenExpirationText = validateContext.Properties.GetTokenValue("expires_at");
- if (!DateTimeOffset.TryParse(accessTokenExpirationText, out var accessTokenExpiration))
- {
- return;
- }
-
- var oidcOptions = oidcOptionsMonitor.Get(oidcScheme);
- var now = oidcOptions.TimeProvider!.GetUtcNow();
- if (now + TimeSpan.FromMinutes(5) < accessTokenExpiration)
- {
- return;
- }
-
- var oidcConfiguration = await oidcOptions.ConfigurationManager!.GetConfigurationAsync(validateContext.HttpContext.RequestAborted);
- var tokenEndpoint = oidcConfiguration.TokenEndpoint ?? throw new InvalidOperationException("Cannot refresh cookie. TokenEndpoint missing!");
-
- using var refreshResponse = await oidcOptions.Backchannel.PostAsync(tokenEndpoint,
- new FormUrlEncodedContent(new Dictionary()
- {
- ["grant_type"] = "refresh_token",
- ["client_id"] = oidcOptions.ClientId,
- ["client_secret"] = oidcOptions.ClientSecret,
- ["scope"] = string.Join(" ", oidcOptions.Scope),
- ["refresh_token"] = validateContext.Properties.GetTokenValue("refresh_token"),
- }));
-
- if (!refreshResponse.IsSuccessStatusCode)
- {
- validateContext.RejectPrincipal();
- return;
- }
-
- var refreshJson = await refreshResponse.Content.ReadAsStringAsync();
- var message = new OpenIdConnectMessage(refreshJson);
-
- var validationParameters = oidcOptions.TokenValidationParameters.Clone();
- if (oidcOptions.ConfigurationManager is BaseConfigurationManager baseConfigurationManager)
- {
- validationParameters.ConfigurationManager = baseConfigurationManager;
- }
- else
- {
- validationParameters.ValidIssuer = oidcConfiguration.Issuer;
- validationParameters.IssuerSigningKeys = oidcConfiguration.SigningKeys;
- }
-
- var validationResult = await oidcOptions.TokenHandler.ValidateTokenAsync(message.IdToken, validationParameters);
-
- if (!validationResult.IsValid)
- {
- validateContext.RejectPrincipal();
- return;
- }
-
- var validatedIdToken = JwtSecurityTokenConverter.Convert(validationResult.SecurityToken as JsonWebToken);
- validatedIdToken.Payload["nonce"] = null;
- oidcTokenValidator.ValidateTokenResponse(new()
- {
- ProtocolMessage = message,
- ClientId = oidcOptions.ClientId,
- ValidatedIdToken = validatedIdToken,
- });
-
- validateContext.ShouldRenew = true;
- validateContext.ReplacePrincipal(new ClaimsPrincipal(validationResult.ClaimsIdentity));
-
- var expiresIn = int.Parse(message.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture);
- var expiresAt = now + TimeSpan.FromSeconds(expiresIn);
- validateContext.Properties.StoreTokens([
- new() { Name = "access_token", Value = message.AccessToken },
- new() { Name = "id_token", Value = message.IdToken },
- new() { Name = "refresh_token", Value = message.RefreshToken },
- new() { Name = "token_type", Value = message.TokenType },
- new() { Name = "expires_at", Value = expiresAt.ToString("o", CultureInfo.InvariantCulture) },
- ]);
- }
-}
diff --git a/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/CookieOidcServiceCollectionExtensions.cs b/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/CookieOidcServiceCollectionExtensions.cs
deleted file mode 100644
index a55e6f0a..00000000
--- a/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/CookieOidcServiceCollectionExtensions.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using Microsoft.AspNetCore.Authentication.Cookies;
-using Microsoft.AspNetCore.Authentication.OpenIdConnect;
-using Microsoft.IdentityModel.Protocols.OpenIdConnect;
-using BlazorWebAppOidcServer;
-
-namespace Microsoft.Extensions.DependencyInjection;
-
-internal static partial class CookieOidcServiceCollectionExtensions
-{
- public static IServiceCollection ConfigureCookieOidc(this IServiceCollection services, string cookieScheme, string oidcScheme)
- {
- services.AddSingleton();
-
- services.AddOptions(cookieScheme).Configure((cookieOptions, refresher) =>
- {
- cookieOptions.Events.OnValidatePrincipal = context => refresher.ValidateOrRefreshCookieAsync(context, oidcScheme);
- });
-
- services.AddOptions(oidcScheme).Configure(oidcOptions =>
- {
- // Request a refresh_token.
- oidcOptions.Scope.Add(OpenIdConnectScope.OfflineAccess);
-
- // Store the refresh_token.
- oidcOptions.SaveTokens = true;
- });
-
- return services;
- }
-}
diff --git a/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/Program.cs b/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/Program.cs
index 1333dd81..76fe760e 100644
--- a/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/Program.cs
+++ b/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/Program.cs
@@ -3,6 +3,7 @@
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using BlazorWebAppOidcServer;
using BlazorWebAppOidcServer.Components;
+using Duende.AccessTokenManagement.OpenIdConnect;
const string MS_OIDC_SCHEME = "MicrosoftOidc";
@@ -18,9 +19,9 @@
// ........................................................................
// Pushed Authorization Requests (PAR) support. By default, the setting is
- // to use PAR if the identity provider's discovery document (usually found
- // at '.well-known/openid-configuration') advertises support for PAR. If
- // you wish to require PAR support for the app, you can assign
+ // to use PAR if the identity provider's discovery document (usually found
+ // at '.well-known/openid-configuration') advertises support for PAR. If
+ // you wish to require PAR support for the app, you can assign
// 'PushedAuthorizationBehavior.Require' to 'PushedAuthorizationBehavior'.
//
// Note that PAR isn't supported by Microsoft Entra, and there are no plans
@@ -30,16 +31,16 @@
// ........................................................................
// ........................................................................
- // The OIDC handler must use a sign-in scheme capable of persisting
+ // The OIDC handler must use a sign-in scheme capable of persisting
// user credentials across requests.
oidcOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// ........................................................................
// ........................................................................
- // The "openid" and "profile" scopes are required for the OIDC handler
- // and included by default. You should enable these scopes here if scopes
- // are provided by "Authentication:Schemes:MicrosoftOidc:Scope"
+ // The "openid" and "profile" scopes are required for the OIDC handler
+ // and included by default. You should enable these scopes here if scopes
+ // are provided by "Authentication:Schemes:MicrosoftOidc:Scope"
// configuration because configuration may overwrite the scopes collection.
//oidcOptions.Scope.Add(OpenIdConnectScope.OpenIdProfile);
@@ -47,7 +48,7 @@
// ........................................................................
// The "Weather.Get" scope for accessing the external web API for weather
- // data. The following example is based on using Microsoft Entra ID in
+ // data. The following example is based on using Microsoft Entra ID in
// an ME-ID tenant domain (the {APP ID URI} placeholder is found in
// the Entra or Azure portal where the web API is exposed). For any other
// identity provider, use the appropriate scope.
@@ -56,8 +57,8 @@
// ........................................................................
// ........................................................................
- // The following paths must match the redirect and post logout redirect
- // paths configured when registering the application with the OIDC provider.
+ // The following paths must match the redirect and post logout redirect
+ // paths configured when registering the application with the OIDC provider.
// The default values are "/signin-oidc" and "/signout-callback-oidc".
//oidcOptions.CallbackPath = new PathString("/signin-oidc");
@@ -65,7 +66,7 @@
// ........................................................................
// ........................................................................
- // The RemoteSignOutPath is the "Front-channel logout URL" for remote single
+ // The RemoteSignOutPath is the "Front-channel logout URL" for remote single
// sign-out. The default value is "/signout-oidc".
//oidcOptions.RemoteSignOutPath = new PathString("/signout-oidc");
@@ -73,12 +74,12 @@
// ........................................................................
// The following example Authority is configured for Microsoft Entra ID
- // and a single-tenant application registration. Set the {TENANT ID}
- // placeholder to the Tenant ID. The "common" Authority
- // https://login.microsoftonline.com/common/v2.0/ should be used
- // for multi-tenant apps. You can also use the "common" Authority for
- // single-tenant apps, but it requires a custom IssuerValidator as shown
- // in the comments below.
+ // and a single-tenant application registration. Set the {TENANT ID}
+ // placeholder to the Tenant ID. The "common" Authority
+ // https://login.microsoftonline.com/common/v2.0/ should be used
+ // for multi-tenant apps. You can also use the "common" Authority for
+ // single-tenant apps, but it requires a custom IssuerValidator as shown
+ // in the comments below.
oidcOptions.Authority = "https://login.microsoftonline.com/{TENANT ID}/v2.0/";
// ........................................................................
@@ -91,20 +92,20 @@
// ........................................................................
// ........................................................................
- // Setting ResponseType to "code" configures the OIDC handler to use
+ // Setting ResponseType to "code" configures the OIDC handler to use
// authorization code flow. Implicit grants and hybrid flows are unnecessary
- // in this mode. In a Microsoft Entra ID app registration, you don't need to
- // select either box for the authorization endpoint to return access tokens
- // or ID tokens. The OIDC handler automatically requests the appropriate
+ // in this mode. In a Microsoft Entra ID app registration, you don't need to
+ // select either box for the authorization endpoint to return access tokens
+ // or ID tokens. The OIDC handler automatically requests the appropriate
// tokens using the code returned from the authorization endpoint.
oidcOptions.ResponseType = OpenIdConnectResponseType.Code;
// ........................................................................
// ........................................................................
- // Set MapInboundClaims to "false" to obtain the original claim types from
- // the token. Many OIDC servers use "name" and "role"/"roles" rather than
- // the SOAP/WS-Fed defaults in ClaimTypes. Adjust these values if your
+ // Set MapInboundClaims to "false" to obtain the original claim types from
+ // the token. Many OIDC servers use "name" and "role"/"roles" rather than
+ // the SOAP/WS-Fed defaults in ClaimTypes. Adjust these values if your
// identity provider uses different claim types.
oidcOptions.MapInboundClaims = false;
@@ -114,7 +115,7 @@
// ........................................................................
// Many OIDC providers work with the default issuer validator, but the
- // configuration must account for the issuer parameterized with "{TENANT ID}"
+ // configuration must account for the issuer parameterized with "{TENANT ID}"
// returned by the "common" endpoint's /.well-known/openid-configuration
// For more information, see
// https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/issues/1731
@@ -124,7 +125,7 @@
// ........................................................................
// ........................................................................
- // OIDC connect options set later via ConfigureCookieOidc
+ // OIDC connect options to handle token refresh
//
// (1) The "offline_access" scope is required for the refresh token.
//
@@ -133,15 +134,14 @@
// use the refresh token to obtain a new access token on access token
// expiration.
// ........................................................................
+ oidcOptions.Scope.Add(OpenIdConnectScope.OfflineAccess);
+ oidcOptions.SaveTokens = true;
})
- .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
-
-// ConfigureCookieOidc attaches a cookie OnValidatePrincipal callback to get
-// a new access token when the current one expires, and reissue a cookie with the
-// new access token saved inside. If the refresh fails, the user will be signed
-// out. OIDC connect options are set for saving tokens and the offline access
-// scope.
-builder.Services.ConfigureCookieOidc(CookieAuthenticationDefaults.AuthenticationScheme, MS_OIDC_SCHEME);
+ .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
+ {
+ // Automatically revoke refresh token at signout
+ options.Events.OnSigningOut = async e => { await e.HttpContext.RevokeRefreshTokenAsync(); };
+ });
builder.Services.AddAuthorization();
@@ -152,12 +152,17 @@
builder.Services.AddHttpContextAccessor();
-builder.Services.AddScoped();
+// Add Duende Access Token Management
+builder.Services.AddOpenIdConnectAccessTokenManagement();
+// Registers HTTP client that uses the managed user access token. It fetches
+// a new access token when the current one expires, and reissue a cookie with the
+// new access token saved inside.OIDC connect options are set for saving tokens and
+// the offline access scope.
builder.Services.AddHttpClient("ExternalApi",
- client => client.BaseAddress = new Uri(builder.Configuration["ExternalApiUri"] ??
+ client => client.BaseAddress = new Uri(builder.Configuration["ExternalApiUri"] ??
throw new Exception("Missing base address!")))
- .AddHttpMessageHandler();
+ .AddUserAccessTokenHandler();
var app = builder.Build();
diff --git a/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/TokenHandler.cs b/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/TokenHandler.cs
deleted file mode 100644
index 0033f7f8..00000000
--- a/9.0/BlazorWebAppOidcServer/BlazorWebAppOidcServer/TokenHandler.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using System.Net.Http.Headers;
-using Microsoft.AspNetCore.Authentication;
-
-namespace BlazorWebAppOidcServer;
-
-public class TokenHandler(IHttpContextAccessor httpContextAccessor) :
- DelegatingHandler
-{
- protected override async Task SendAsync(
- HttpRequestMessage request, CancellationToken cancellationToken)
- {
- if (httpContextAccessor.HttpContext is null)
- {
- throw new Exception("HttpContext not available");
- }
-
- var accessToken = await httpContextAccessor.HttpContext
- .GetTokenAsync("access_token");
-
- request.Headers.Authorization =
- new AuthenticationHeaderValue("Bearer", accessToken);
-
- return await base.SendAsync(request, cancellationToken);
- }
-}
diff --git a/9.0/BlazorWebAppOidcServer/MinimalApiJwt/Program.cs b/9.0/BlazorWebAppOidcServer/MinimalApiJwt/Program.cs
index 3d855905..99bba1e2 100644
--- a/9.0/BlazorWebAppOidcServer/MinimalApiJwt/Program.cs
+++ b/9.0/BlazorWebAppOidcServer/MinimalApiJwt/Program.cs
@@ -5,7 +5,7 @@
{
// {TENANT ID} is the directory (tenant) ID.
//
- // Authority format {AUTHORITY} matches the issurer (`iss`) of the JWT returned by the identity provider.
+ // Authority format {AUTHORITY} matches the issuer (`iss`) of the JWT returned by the identity provider.
//
// Authority format {AUTHORITY} for ME-ID tenant type: https://sts.windows.net/{TENANT ID}/
// Authority format {AUTHORITY} for B2C tenant type: https://login.microsoftonline.com/{TENANT ID}/v2.0/
@@ -13,9 +13,9 @@
jwtOptions.Authority = "{AUTHORITY}";
//
// The following should match just the path of the Application ID URI configured when adding the "Weather.Get" scope
- // under "Expose an API" in the Azure or Entra portal. {CLIENT ID} is the application (client) ID of this
+ // under "Expose an API" in the Azure or Entra portal. {CLIENT ID} is the application (client) ID of this
// app's registration in the Azure portal.
- //
+ //
// Audience format {AUDIENCE} for ME-ID tenant type: api://{CLIENT ID}
// Audience format {AUDIENCE} for B2C tenant type: https://{DIRECTORY NAME}.onmicrosoft.com/{CLIENT ID}
//
@@ -23,7 +23,7 @@
});
builder.Services.AddAuthorization();
-// Add OpenApi
+// Add OpenApi
builder.Services.AddOpenApi();
var app = builder.Build();