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