From 3fc4df0bbb9f1f7eb3a6c0a44ee7322054170c22 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Wed, 7 Jan 2026 08:05:21 -0500 Subject: [PATCH 01/22] Opaque (reference) access token guidance --- .../security/blazor-web-app-with-oidc.md | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index 81e79bf26364..e5576dc74d31 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -1465,6 +1465,134 @@ At this point, Razor components can adopt [role-based and policy-based authoriza * Security groups appear in `groups` claims, one claim per group. The security group GUIDs appear in the Azure portal when you create a security group and are listed when selecting **Identity** > **Overview** > **Groups** > **View**. * Built-in ME-ID administrator roles appear in `wids` claims, one claim per role. The `wids` claim with a value of `b79fbf4d-3ef9-4689-8143-76b194e85509` is always sent by ME-ID for non-guest accounts of the tenant and doesn't refer to an administrator role. Administrator role GUIDs (*role template IDs*) appear in the Azure portal when selecting **Roles & admins**, followed by the ellipsis (**…**) > **Description** for the listed role. The role template IDs are also listed in [Microsoft Entra built-in roles (Entra documentation)](/entra/identity/role-based-access-control/permissions-reference). +## Opaque (reference) access token support + + supports opaque tokens because it doesn't perform access token validation when configured for Proof Key for Code Exchange (PKCE) authorization code flow. It relies on the ASP.NET Core server's HTTPS backchannel to the OIDC authentication service to obtain the ID token using the authorization code received when the user redirects back to the ASP.NET Core app after signing in. If the app is only required to log a user in with OIDC to get a valid authentication cookie, opaque access tokens are supported without modifying the app. Only if the opaque (reference) access token acquired by is passed to another service that attempts to validate it with results in a failure to authenticate the user for accessing the external service. Unlike self-contained JWTs, opaque tokens require a request to an authorization server to validate their status and retrieve claims. To work around this limitation, use a third-party API, such as the [Duende Introspection Authentication Handler](https://docs.duendesoftware.com/introspection/) or create a [custom `AuthenticationHandler`](xref:security/authentication/index#authentication-handler) to validate an opaque token. + +> [!IMPORTANT] +> [Duende Software](https://duendesoftware.com/) isn't owned or controlled by Microsoft and might require you to pay a license fee for production use of the Duende Introspection Authentication Handler. + +The following and associated configuration and helper code is provided as a starting point for further development. The handler extracts the opaque token from the `Authorization` header for an HTTP call to an authorization server's introspection endpoint and creates an containing the user's claims. + +`HttpContextExtensions.cs`: + +```csharp +namespace MinimalApiJwt.Extensions; + +public static class HttpContextExtensions +{ + public static string? ExtractBearerToken(this HttpRequest request) + { + var authorizationHeader = request.Headers["Authorization"].ToString(); + + if (!string.IsNullOrEmpty(authorizationHeader) && + authorizationHeader.StartsWith("Bearer ", + StringComparison.OrdinalIgnoreCase)) + { + var token = authorizationHeader["Bearer ".Length..].Trim(); + + if (!string.IsNullOrEmpty(token)) + { + return token; + } + } + + return null; + } +} +``` + +`OpaqueTokenAuthenticationOptions.cs`: + +```csharp +using Microsoft.AspNetCore.Authentication; + +namespace MinimalApiJwt.Authentication; + +public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions +{ + public const string DefaultScheme = "OpaqueTokenAuthentication"; + public string? IntrospectionEndpoint { get; set; } + public string? ClientId { get; set; } +} +``` + +`OpaqueTokenAuthenticationHandler.cs`: + +```csharp +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Options; +using MinimalApiJwt.Authentication; +using MinimalApiJwt.Extensions; + +namespace MinimalApiJwt.Services; + +public class OpaqueTokenAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) +{ + protected override Task HandleAuthenticateAsync() + { + var token = Request.ExtractBearerToken(); + + if (token is null) + { + var failedResult = AuthenticateResult.Fail("Authorization failed."); + return Task.FromResult(failedResult); + } + + /* Validate the opaque (reference) access token + + Make an HTTP call to the authorization server's introspection endpoint + with the token and the API's credentials, process the response to + determine if the token is valid. + + If the token is invalid, return a failed authorization result. + + If the token is valid, create an AuthenticationTicket containing the + user's claims. + */ + + var claims = new[] { new Claim(ClaimTypes.Name, "user_id") }; + var identity = new ClaimsIdentity(claims, + OpaqueTokenAuthenticationOptions.DefaultScheme); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, + OpaqueTokenAuthenticationOptions.DefaultScheme); + + var result = AuthenticateResult.Success(ticket); + + return Task.FromResult(result); + } +} +``` + +In the `Program` file: + +```csharp +builder.Services.AddHttpClient(); +builder.Services.AddAuthentication() + .AddScheme( + OpaqueTokenAuthenticationOptions.DefaultScheme, + options => + { + options.IntrospectionEndpoint = "{AUTH SERVER URI}"; + options.ClientId = "{API CLIENT ID}"; + }); +``` + +The preceding example's placeholders: + +* `{AUTH SERVER URI}`: Authentication server URI +* `{API CLIENT ID}`: API Client ID + +Built-in opaque access token support is under consideration for a future release of .NET. For more information, see [Opaque - reference token validation (`dotnet/aspnetcore` #46026)](https://github.com/dotnet/aspnetcore/issues/46026). + ## Alternative: Duende Access Token Management In the sample app, a custom cookie refresher (`CookieOidcRefresher.cs`) implementation is used to perform automatic non-interactive token refresh. An alternative solution can be found in the open source [`Duende.AccessTokenManagement.OpenIdConnect` package](https://docs.duendesoftware.com/accesstokenmanagement/web-apps/). From 85548595603572dc728226568a9afb08b22e1047 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Wed, 7 Jan 2026 08:47:09 -0500 Subject: [PATCH 02/22] Updates --- aspnetcore/blazor/security/blazor-web-app-with-oidc.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index e5576dc74d31..2a09af7efce1 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -1467,7 +1467,9 @@ At this point, Razor components can adopt [role-based and policy-based authoriza ## Opaque (reference) access token support - supports opaque tokens because it doesn't perform access token validation when configured for Proof Key for Code Exchange (PKCE) authorization code flow. It relies on the ASP.NET Core server's HTTPS backchannel to the OIDC authentication service to obtain the ID token using the authorization code received when the user redirects back to the ASP.NET Core app after signing in. If the app is only required to log a user in with OIDC to get a valid authentication cookie, opaque access tokens are supported without modifying the app. Only if the opaque (reference) access token acquired by is passed to another service that attempts to validate it with results in a failure to authenticate the user for accessing the external service. Unlike self-contained JWTs, opaque tokens require a request to an authorization server to validate their status and retrieve claims. To work around this limitation, use a third-party API, such as the [Duende Introspection Authentication Handler](https://docs.duendesoftware.com/introspection/) or create a [custom `AuthenticationHandler`](xref:security/authentication/index#authentication-handler) to validate an opaque token. + supports opaque tokens because it doesn't perform access token validation when configured for Proof Key for Code Exchange (PKCE) authorization code flow. It relies on the ASP.NET Core server's HTTPS backchannel to the OIDC authentication service to obtain the ID token using the authorization code received when the user redirects back to the ASP.NET Core app after signing in. If the app is only required to log a user in with OIDC to get a valid authentication cookie, opaque access tokens are supported without modifying the app. + +Only if the opaque token acquired by is passed to another service that attempts to validate it with is there a failure to authenticate the user. Unlike self-contained JWTs, opaque tokens require a request to an authorization server to validate their status and retrieve claims. To work around this limitation, either use a third-party API, such as the [Duende Introspection Authentication Handler](https://docs.duendesoftware.com/introspection/), or create a [custom `AuthenticationHandler`](xref:security/authentication/index#authentication-handler) to validate the token. > [!IMPORTANT] > [Duende Software](https://duendesoftware.com/) isn't owned or controlled by Microsoft and might require you to pay a license fee for production use of the Duende Introspection Authentication Handler. From d7b921b710d4b46ae48ce3595992123e4bae2c0f Mon Sep 17 00:00:00 2001 From: Luke Latham <1622880+guardrex@users.noreply.github.com> Date: Wed, 7 Jan 2026 09:03:01 -0500 Subject: [PATCH 03/22] Update aspnetcore/blazor/security/blazor-web-app-with-oidc.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- aspnetcore/blazor/security/blazor-web-app-with-oidc.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index 2a09af7efce1..761df811a103 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -1560,7 +1560,8 @@ public class OpaqueTokenAuthenticationHandler( user's claims. */ - var claims = new[] { new Claim(ClaimTypes.Name, "user_id") }; + // TODO: Replace "{USER ID}" with a claim value extracted from the token introspection response. + var claims = new[] { new Claim(ClaimTypes.Name, "{USER ID}") }; var identity = new ClaimsIdentity(claims, OpaqueTokenAuthenticationOptions.DefaultScheme); var principal = new ClaimsPrincipal(identity); From a2279205351225be43362d083dc1d333caef3481 Mon Sep 17 00:00:00 2001 From: Luke Latham <1622880+guardrex@users.noreply.github.com> Date: Wed, 7 Jan 2026 09:03:32 -0500 Subject: [PATCH 04/22] Update aspnetcore/blazor/security/blazor-web-app-with-oidc.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- aspnetcore/blazor/security/blazor-web-app-with-oidc.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index 761df811a103..06b49f790663 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -1544,7 +1544,7 @@ public class OpaqueTokenAuthenticationHandler( if (token is null) { - var failedResult = AuthenticateResult.Fail("Authorization failed."); + var failedResult = AuthenticateResult.Fail("Bearer token not found in Authorization header."); return Task.FromResult(failedResult); } From a1654f70a4f7fbe3dfcb1985d788d514e45cd1e8 Mon Sep 17 00:00:00 2001 From: Luke Latham <1622880+guardrex@users.noreply.github.com> Date: Wed, 7 Jan 2026 09:04:46 -0500 Subject: [PATCH 05/22] Update aspnetcore/blazor/security/blazor-web-app-with-oidc.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- aspnetcore/blazor/security/blazor-web-app-with-oidc.md | 1 - 1 file changed, 1 deletion(-) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index 06b49f790663..a99e42caa3b0 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -1525,7 +1525,6 @@ public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions using System.Security.Claims; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; using MinimalApiJwt.Authentication; using MinimalApiJwt.Extensions; From e66efbc752fad7dafe6513ff2135a4ab24681bd8 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Wed, 7 Jan 2026 09:08:08 -0500 Subject: [PATCH 06/22] Updates --- aspnetcore/blazor/security/blazor-web-app-with-oidc.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index a99e42caa3b0..fc66435eab9d 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -1469,7 +1469,7 @@ At this point, Razor components can adopt [role-based and policy-based authoriza supports opaque tokens because it doesn't perform access token validation when configured for Proof Key for Code Exchange (PKCE) authorization code flow. It relies on the ASP.NET Core server's HTTPS backchannel to the OIDC authentication service to obtain the ID token using the authorization code received when the user redirects back to the ASP.NET Core app after signing in. If the app is only required to log a user in with OIDC to get a valid authentication cookie, opaque access tokens are supported without modifying the app. -Only if the opaque token acquired by is passed to another service that attempts to validate it with is there a failure to authenticate the user. Unlike self-contained JWTs, opaque tokens require a request to an authorization server to validate their status and retrieve claims. To work around this limitation, either use a third-party API, such as the [Duende Introspection Authentication Handler](https://docs.duendesoftware.com/introspection/), or create a [custom `AuthenticationHandler`](xref:security/authentication/index#authentication-handler) to validate the token. +A failure occurs only when the opaque token acquired by is passed to another service that attempts to validate it with . Unlike self-contained JWTs, opaque tokens require a request to an authorization server to validate their status and retrieve claims. To work around this limitation, either use a third-party API, such as the [Duende Introspection Authentication Handler](https://docs.duendesoftware.com/introspection/), or create a [custom `AuthenticationHandler`](xref:security/authentication/index#authentication-handler) to validate the token. > [!IMPORTANT] > [Duende Software](https://duendesoftware.com/) isn't owned or controlled by Microsoft and might require you to pay a license fee for production use of the Duende Introspection Authentication Handler. @@ -1543,7 +1543,8 @@ public class OpaqueTokenAuthenticationHandler( if (token is null) { - var failedResult = AuthenticateResult.Fail("Bearer token not found in Authorization header."); + var failedResult = AuthenticateResult.Fail( + "Bearer token not found in Authorization header."); return Task.FromResult(failedResult); } @@ -1559,7 +1560,8 @@ public class OpaqueTokenAuthenticationHandler( user's claims. */ - // TODO: Replace "{USER ID}" with a claim value extracted from the token introspection response. + // TODO: Replace the '{USER ID}' placeholder with a claim value extracted + // from the token introspection response. var claims = new[] { new Claim(ClaimTypes.Name, "{USER ID}") }; var identity = new ClaimsIdentity(claims, OpaqueTokenAuthenticationOptions.DefaultScheme); From 12e20b9f5d458f04b9e5db010ad2aa914131b36d Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Wed, 7 Jan 2026 09:09:59 -0500 Subject: [PATCH 07/22] Updates --- aspnetcore/blazor/security/blazor-web-app-with-oidc.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index fc66435eab9d..19547c8110ef 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -1585,14 +1585,14 @@ builder.Services.AddAuthentication() OpaqueTokenAuthenticationOptions.DefaultScheme, options => { - options.IntrospectionEndpoint = "{AUTH SERVER URI}"; + options.IntrospectionEndpoint = "{AUTH SERVER INTROSPECTION URI}"; options.ClientId = "{API CLIENT ID}"; }); ``` The preceding example's placeholders: -* `{AUTH SERVER URI}`: Authentication server URI +* `{AUTH SERVER INTROSPECTION URI}`: Authentication server's introspection URI * `{API CLIENT ID}`: API Client ID Built-in opaque access token support is under consideration for a future release of .NET. For more information, see [Opaque - reference token validation (`dotnet/aspnetcore` #46026)](https://github.com/dotnet/aspnetcore/issues/46026). From e8a65cd7d65d07ef0c2c767614be3f544e357631 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Tue, 19 May 2026 07:39:07 -0400 Subject: [PATCH 08/22] Updates --- .../security/blazor-web-app-with-oidc.md | 127 ++++++++++++++---- 1 file changed, 102 insertions(+), 25 deletions(-) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index 19547c8110ef..bd019ff453af 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -1474,9 +1474,29 @@ A failure occurs only when the opaque token acquired by [!IMPORTANT] > [Duende Software](https://duendesoftware.com/) isn't owned or controlled by Microsoft and might require you to pay a license fee for production use of the Duende Introspection Authentication Handler. -The following and associated configuration and helper code is provided as a starting point for further development. The handler extracts the opaque token from the `Authorization` header for an HTTP call to an authorization server's introspection endpoint and creates an containing the user's claims. +The following and associated configuration and helper code is provided as a general approach, which might require further development to suit a specific authorization server's requirements. The following handler extracts the opaque token from the `Authorization` header for an HTTP call to an authorization server's introspection endpoint and creates an containing the user's claims. -`HttpContextExtensions.cs`: +Calling an authorization server's introspection endpoint requires authentication. The following example relies on setting the client secret for authentication in the request's Authorization header (base64 encoded credentials) using the [Secret Manager tool](xref:security/app-secrets) for local development and testing. + +[!INCLUDE[](~/blazor/security/includes/secure-authentication-flows.md)] + +In the following handler, the authorization server's introspection endpoint client secret uses the configuration key `Authentication:Schemes:AuthServer:ClientSecret`. + +If the Blazor server project hasn't been initialized for the Secret Manager tool, use a command shell, such as the Developer PowerShell command shell in Visual Studio, to execute the following command. Before executing the command, change the directory with the `cd` command to the server project's directory. The command establishes a user secrets identifier (`` in the server app's project file): + +```dotnetcli +dotnet user-secrets init +``` + +Execute the following command to set the client secret for the authorization server. The `{SECRET}` placeholder is the client secret: + +```dotnetcli +dotnet user-secrets set "Authentication:Schemes:AuthServer:ClientSecret" "{SECRET}" +``` + +If using Visual Studio, you can confirm the secret is set by right-clicking the server project in **Solution Explorer** and selecting **Manage User Secrets**. + +`Extensions/HttpContextExtensions.cs`: ```csharp namespace MinimalApiJwt.Extensions; @@ -1504,7 +1524,7 @@ public static class HttpContextExtensions } ``` -`OpaqueTokenAuthenticationOptions.cs`: +`Authentication/OpaqueTokenAuthenticationOptions.cs`: ```csharp using Microsoft.AspNetCore.Authentication; @@ -1522,8 +1542,10 @@ public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions `OpaqueTokenAuthenticationHandler.cs`: ```csharp +using System.Net.Http.Headers; using System.Security.Claims; using System.Text.Encodings.Web; +using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; using MinimalApiJwt.Authentication; @@ -1534,44 +1556,99 @@ namespace MinimalApiJwt.Services; public class OpaqueTokenAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, - UrlEncoder encoder) + UrlEncoder encoder, + IConfiguration config) : AuthenticationHandler(options, logger, encoder) { - protected override Task HandleAuthenticateAsync() + protected override async Task HandleAuthenticateAsync() { - var token = Request.ExtractBearerToken(); + var opaqueToken = Request.ExtractBearerToken(); - if (token is null) + if (opaqueToken is null) { var failedResult = AuthenticateResult.Fail( "Bearer token not found in Authorization header."); - return Task.FromResult(failedResult); + return failedResult; } - /* Validate the opaque (reference) access token + /* + The following example attempts to validate the opaque + (reference) access token. - Make an HTTP call to the authorization server's introspection endpoint - with the token and the API's credentials, process the response to - determine if the token is valid. + An HTTP call is made to the authorization server's introspection + endpoint with the token and the API's credentials. The response + is processed to determine if the token is valid. - If the token is invalid, return a failed authorization result. + If the token is valid, an AuthenticationTicket is created + containing the user's claims. - If the token is valid, create an AuthenticationTicket containing the - user's claims. + If the token is invalid, a failed authorization result is + returned. + + Values for the authentication server introspection URI + ('{AUTH SERVER INTROSPECTION URI}') and the API client ID + ('{API CLIENT ID}') can be supplied from app settings + or any other configuration source. */ - // TODO: Replace the '{USER ID}' placeholder with a claim value extracted - // from the token introspection response. - var claims = new[] { new Claim(ClaimTypes.Name, "{USER ID}") }; - var identity = new ClaimsIdentity(claims, - OpaqueTokenAuthenticationOptions.DefaultScheme); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, - OpaqueTokenAuthenticationOptions.DefaultScheme); + var introspectionUri = "{AUTH SERVER INTROSPECTION URI}"; + var clientId = "{API CLIENT ID}"; + var clientSecret = config["Authentication:Schemes:AuthServer:ClientSecret"]; + + using var client = new HttpClient(); - var result = AuthenticateResult.Success(ticket); + // Set the Authorization header (base64 encoded credentials) + var authString = Convert.ToBase64String( + System.Text.Encoding.ASCII.GetBytes($"{clientId}:{clientSecret}")); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Basic", authString); - return Task.FromResult(result); + // Prepare the form-encoded body containing the token + var content = new FormUrlEncodedContent(new[] + { + new KeyValuePair("token", opaqueToken) + // NOTE: Some servers require "token_type_hint", e.g., "access_token" + }); + + // Post to the introspection endpoint + var response = await client.PostAsync(introspectionUri, content); + + if (!response.IsSuccessStatusCode) + { + var failedResult = AuthenticateResult.Fail( + "Introspection endpoint failure."); + + return failedResult; + } + + // Parse the JSON response + var responseString = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(responseString); + + // The 'active' property determines if the token is valid and not expired + var tokenIsValid = doc.RootElement.GetProperty("active").GetBoolean(); + + if (tokenIsValid) + { + // TODO: Replace the '{USER ID}' placeholder with extracted claim value + // from the token introspection response + var claims = new[] { new Claim(ClaimTypes.Name, "{USER ID}") }; + var identity = new ClaimsIdentity(claims, + OpaqueTokenAuthenticationOptions.DefaultScheme); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, + OpaqueTokenAuthenticationOptions.DefaultScheme); + + var result = AuthenticateResult.Success(ticket); + + return result; + } + else + { + var failedResult = AuthenticateResult.Fail("Bearer token invalid."); + + return failedResult; + } } } ``` From 6f1eb4c2ac59eb7cc55ce89006e76731125bcd4a Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Tue, 19 May 2026 07:51:59 -0400 Subject: [PATCH 09/22] Updates --- aspnetcore/blazor/security/blazor-web-app-with-oidc.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index bd019ff453af..0691756a59e0 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -1604,11 +1604,11 @@ public class OpaqueTokenAuthenticationHandler( new AuthenticationHeaderValue("Basic", authString); // Prepare the form-encoded body containing the token - var content = new FormUrlEncodedContent(new[] - { + var content = new FormUrlEncodedContent( + [ new KeyValuePair("token", opaqueToken) // NOTE: Some servers require "token_type_hint", e.g., "access_token" - }); + ]); // Post to the introspection endpoint var response = await client.PostAsync(introspectionUri, content); From 0c088e3d93191ae851ed6b2f25f3fd557610f51f Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Tue, 19 May 2026 08:17:38 -0400 Subject: [PATCH 10/22] Updates --- aspnetcore/blazor/security/blazor-web-app-with-oidc.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index 0691756a59e0..e8c42da85e4c 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -1467,6 +1467,8 @@ At this point, Razor components can adopt [role-based and policy-based authoriza ## Opaque (reference) access token support +*The following guidance requires an authentication server that supports opaque (reference) access token support. Currently, Microsoft Entra doesn't support opaque access token validation.* + supports opaque tokens because it doesn't perform access token validation when configured for Proof Key for Code Exchange (PKCE) authorization code flow. It relies on the ASP.NET Core server's HTTPS backchannel to the OIDC authentication service to obtain the ID token using the authorization code received when the user redirects back to the ASP.NET Core app after signing in. If the app is only required to log a user in with OIDC to get a valid authentication cookie, opaque access tokens are supported without modifying the app. A failure occurs only when the opaque token acquired by is passed to another service that attempts to validate it with . Unlike self-contained JWTs, opaque tokens require a request to an authorization server to validate their status and retrieve claims. To work around this limitation, either use a third-party API, such as the [Duende Introspection Authentication Handler](https://docs.duendesoftware.com/introspection/), or create a [custom `AuthenticationHandler`](xref:security/authentication/index#authentication-handler) to validate the token. @@ -1607,7 +1609,8 @@ public class OpaqueTokenAuthenticationHandler( var content = new FormUrlEncodedContent( [ new KeyValuePair("token", opaqueToken) - // NOTE: Some servers require "token_type_hint", e.g., "access_token" + // NOTE: Some servers require "token_type_hint", for example + // set to "access_token" ]); // Post to the introspection endpoint @@ -1672,6 +1675,8 @@ The preceding example's placeholders: * `{AUTH SERVER INTROSPECTION URI}`: Authentication server's introspection URI * `{API CLIENT ID}`: API Client ID +Values for the authentication server introspection URI (`{AUTH SERVER INTROSPECTION URI}`) and the API client ID (`{API CLIENT ID}`) can be supplied from app settings or any other configuration source. + Built-in opaque access token support is under consideration for a future release of .NET. For more information, see [Opaque - reference token validation (`dotnet/aspnetcore` #46026)](https://github.com/dotnet/aspnetcore/issues/46026). ## Alternative: Duende Access Token Management From f2befd1c387b643dab4d0ee409529fcb70a05785 Mon Sep 17 00:00:00 2001 From: Luke Latham <1622880+guardrex@users.noreply.github.com> Date: Tue, 19 May 2026 08:50:38 -0400 Subject: [PATCH 11/22] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Luke Latham <1622880+guardrex@users.noreply.github.com> --- .../blazor/security/blazor-web-app-with-oidc.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index e8c42da85e4c..6d1a0e1e2f87 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -1467,7 +1467,7 @@ At this point, Razor components can adopt [role-based and policy-based authoriza ## Opaque (reference) access token support -*The following guidance requires an authentication server that supports opaque (reference) access token support. Currently, Microsoft Entra doesn't support opaque access token validation.* +*The following guidance requires an authentication server that supports opaque (reference) access tokens. Currently, Microsoft Entra doesn't support opaque access token validation.* supports opaque tokens because it doesn't perform access token validation when configured for Proof Key for Code Exchange (PKCE) authorization code flow. It relies on the ASP.NET Core server's HTTPS backchannel to the OIDC authentication service to obtain the ID token using the authorization code received when the user redirects back to the ASP.NET Core app after signing in. If the app is only required to log a user in with OIDC to get a valid authentication cookie, opaque access tokens are supported without modifying the app. @@ -1541,7 +1541,7 @@ public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions } ``` -`OpaqueTokenAuthenticationHandler.cs`: +`Authentication/OpaqueTokenAuthenticationHandler.cs`: ```csharp using System.Net.Http.Headers; @@ -1550,10 +1550,9 @@ using System.Text.Encodings.Web; using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; -using MinimalApiJwt.Authentication; using MinimalApiJwt.Extensions; -namespace MinimalApiJwt.Services; +namespace MinimalApiJwt.Authentication; public class OpaqueTokenAuthenticationHandler( IOptionsMonitor options, @@ -1593,8 +1592,8 @@ public class OpaqueTokenAuthenticationHandler( or any other configuration source. */ - var introspectionUri = "{AUTH SERVER INTROSPECTION URI}"; - var clientId = "{API CLIENT ID}"; + var introspectionUri = options.IntrospectionEndpoint; + var clientId = options.ClientId; var clientSecret = config["Authentication:Schemes:AuthServer:ClientSecret"]; using var client = new HttpClient(); From 14b0851802e05d53b02530336b73ff4a108e1366 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Tue, 19 May 2026 08:53:51 -0400 Subject: [PATCH 12/22] Updates --- aspnetcore/blazor/security/blazor-web-app-with-oidc.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index 6d1a0e1e2f87..8e6083b757e4 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -1558,7 +1558,8 @@ public class OpaqueTokenAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, - IConfiguration config) + IConfiguration config, + IHttpClientFactory httpClientFactory) : AuthenticationHandler(options, logger, encoder) { protected override async Task HandleAuthenticateAsync() @@ -1585,18 +1586,13 @@ public class OpaqueTokenAuthenticationHandler( If the token is invalid, a failed authorization result is returned. - - Values for the authentication server introspection URI - ('{AUTH SERVER INTROSPECTION URI}') and the API client ID - ('{API CLIENT ID}') can be supplied from app settings - or any other configuration source. */ var introspectionUri = options.IntrospectionEndpoint; var clientId = options.ClientId; var clientSecret = config["Authentication:Schemes:AuthServer:ClientSecret"]; - using var client = new HttpClient(); + using var client = httpClientFactory.CreateClient(); // Set the Authorization header (base64 encoded credentials) var authString = Convert.ToBase64String( From 8b16fd9127ebdd5d61f9f27ac20b7d647c190d36 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Tue, 19 May 2026 09:03:59 -0400 Subject: [PATCH 13/22] Move to the additional resources article and cross-link --- .../blazor/security/additional-scenarios.md | 209 +++++++++++++++++ .../security/blazor-web-app-with-entra.md | 1 + .../security/blazor-web-app-with-oidc.md | 210 +----------------- aspnetcore/blazor/security/index.md | 2 + 4 files changed, 213 insertions(+), 209 deletions(-) diff --git a/aspnetcore/blazor/security/additional-scenarios.md b/aspnetcore/blazor/security/additional-scenarios.md index c077fd2445bf..a73cac08e125 100644 --- a/aspnetcore/blazor/security/additional-scenarios.md +++ b/aspnetcore/blazor/security/additional-scenarios.md @@ -1063,3 +1063,212 @@ builder.Services.AddHttpClient("HttpMessageHandler") ``` :::moniker-end + +## Opaque (reference) access token support + +*The following guidance requires an authentication server that supports opaque (reference) access tokens. Currently, Microsoft Entra doesn't support opaque access token validation.* + + supports opaque tokens because it doesn't perform access token validation when configured for Proof Key for Code Exchange (PKCE) authorization code flow. It relies on the ASP.NET Core server's HTTPS backchannel to the OIDC authentication service to obtain the ID token using the authorization code received when the user redirects back to the ASP.NET Core app after signing in. If the app is only required to log a user in with OIDC to get a valid authentication cookie, opaque access tokens are supported without modifying the app. + +A failure occurs only when the opaque token acquired by is passed to another service that attempts to validate it with . Unlike self-contained JWTs, opaque tokens require a request to an authorization server to validate their status and retrieve claims. To work around this limitation, either use a third-party API, such as the [Duende Introspection Authentication Handler](https://docs.duendesoftware.com/introspection/), or create a [custom `AuthenticationHandler`](xref:security/authentication/index#authentication-handler) to validate the token. + +> [!IMPORTANT] +> [Duende Software](https://duendesoftware.com/) isn't owned or controlled by Microsoft and might require you to pay a license fee for production use of the Duende Introspection Authentication Handler. + +The following and associated configuration and helper code is provided as a general approach, which might require further development to suit a specific authorization server's requirements. The following handler extracts the opaque token from the `Authorization` header for an HTTP call to an authorization server's introspection endpoint and creates an containing the user's claims. + +Calling an authorization server's introspection endpoint requires authentication. The following example relies on setting the client secret for authentication in the request's Authorization header (base64 encoded credentials) using the [Secret Manager tool](xref:security/app-secrets) for local development and testing. + +[!INCLUDE[](~/blazor/security/includes/secure-authentication-flows.md)] + +In the following handler, the authorization server's introspection endpoint client secret uses the configuration key `Authentication:Schemes:AuthServer:ClientSecret`. + +If the Blazor server project hasn't been initialized for the Secret Manager tool, use a command shell, such as the Developer PowerShell command shell in Visual Studio, to execute the following command. Before executing the command, change the directory with the `cd` command to the server project's directory. The command establishes a user secrets identifier (`` in the server app's project file): + +```dotnetcli +dotnet user-secrets init +``` + +Execute the following command to set the client secret for the authorization server. The `{SECRET}` placeholder is the client secret: + +```dotnetcli +dotnet user-secrets set "Authentication:Schemes:AuthServer:ClientSecret" "{SECRET}" +``` + +If using Visual Studio, you can confirm the secret is set by right-clicking the server project in **Solution Explorer** and selecting **Manage User Secrets**. + +`Extensions/HttpContextExtensions.cs`: + +```csharp +namespace MinimalApiJwt.Extensions; + +public static class HttpContextExtensions +{ + public static string? ExtractBearerToken(this HttpRequest request) + { + var authorizationHeader = request.Headers["Authorization"].ToString(); + + if (!string.IsNullOrEmpty(authorizationHeader) && + authorizationHeader.StartsWith("Bearer ", + StringComparison.OrdinalIgnoreCase)) + { + var token = authorizationHeader["Bearer ".Length..].Trim(); + + if (!string.IsNullOrEmpty(token)) + { + return token; + } + } + + return null; + } +} +``` + +`Authentication/OpaqueTokenAuthenticationOptions.cs`: + +```csharp +using Microsoft.AspNetCore.Authentication; + +namespace MinimalApiJwt.Authentication; + +public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions +{ + public const string DefaultScheme = "OpaqueTokenAuthentication"; + public string? IntrospectionEndpoint { get; set; } + public string? ClientId { get; set; } +} +``` + +`Authentication/OpaqueTokenAuthenticationHandler.cs`: + +```csharp +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using MinimalApiJwt.Extensions; + +namespace MinimalApiJwt.Authentication; + +public class OpaqueTokenAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IConfiguration config, + IHttpClientFactory httpClientFactory) + : AuthenticationHandler(options, logger, encoder) +{ + protected override async Task HandleAuthenticateAsync() + { + var opaqueToken = Request.ExtractBearerToken(); + + if (opaqueToken is null) + { + var failedResult = AuthenticateResult.Fail( + "Bearer token not found in Authorization header."); + return failedResult; + } + + /* + The following example attempts to validate the opaque + (reference) access token. + + An HTTP call is made to the authorization server's introspection + endpoint with the token and the API's credentials. The response + is processed to determine if the token is valid. + + If the token is valid, an AuthenticationTicket is created + containing the user's claims. + + If the token is invalid, a failed authorization result is + returned. + */ + + var introspectionUri = options.IntrospectionEndpoint; + var clientId = options.ClientId; + var clientSecret = config["Authentication:Schemes:AuthServer:ClientSecret"]; + + using var client = httpClientFactory.CreateClient(); + + // Set the Authorization header (base64 encoded credentials) + var authString = Convert.ToBase64String( + System.Text.Encoding.ASCII.GetBytes($"{clientId}:{clientSecret}")); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Basic", authString); + + // Prepare the form-encoded body containing the token + var content = new FormUrlEncodedContent( + [ + new KeyValuePair("token", opaqueToken) + // NOTE: Some servers require "token_type_hint", for example + // set to "access_token" + ]); + + // Post to the introspection endpoint + var response = await client.PostAsync(introspectionUri, content); + + if (!response.IsSuccessStatusCode) + { + var failedResult = AuthenticateResult.Fail( + "Introspection endpoint failure."); + + return failedResult; + } + + // Parse the JSON response + var responseString = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(responseString); + + // The 'active' property determines if the token is valid and not expired + var tokenIsValid = doc.RootElement.GetProperty("active").GetBoolean(); + + if (tokenIsValid) + { + // TODO: Replace the '{USER ID}' placeholder with extracted claim value + // from the token introspection response + var claims = new[] { new Claim(ClaimTypes.Name, "{USER ID}") }; + var identity = new ClaimsIdentity(claims, + OpaqueTokenAuthenticationOptions.DefaultScheme); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, + OpaqueTokenAuthenticationOptions.DefaultScheme); + + var result = AuthenticateResult.Success(ticket); + + return result; + } + else + { + var failedResult = AuthenticateResult.Fail("Bearer token invalid."); + + return failedResult; + } + } +} +``` + +In the `Program` file: + +```csharp +builder.Services.AddHttpClient(); +builder.Services.AddAuthentication() + .AddScheme( + OpaqueTokenAuthenticationOptions.DefaultScheme, + options => + { + options.IntrospectionEndpoint = "{AUTH SERVER INTROSPECTION URI}"; + options.ClientId = "{API CLIENT ID}"; + }); +``` + +The preceding example's placeholders: + +* `{AUTH SERVER INTROSPECTION URI}`: Authentication server's introspection URI +* `{API CLIENT ID}`: API Client ID + +Values for the authentication server introspection URI (`{AUTH SERVER INTROSPECTION URI}`) and the API client ID (`{API CLIENT ID}`) can be supplied from app settings or any other configuration source. + +Built-in opaque access token support is under consideration for a future release of .NET. For more information, see [Opaque - reference token validation (`dotnet/aspnetcore` #46026)](https://github.com/dotnet/aspnetcore/issues/46026). diff --git a/aspnetcore/blazor/security/blazor-web-app-with-entra.md b/aspnetcore/blazor/security/blazor-web-app-with-entra.md index 767dfcf9c61f..098f02d90753 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-entra.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-entra.md @@ -1175,3 +1175,4 @@ We also recommend using a shared [Data Protection](xref:security/data-protection * * * +* [Opaque (reference) access token support](xref:blazor/security/additional-scenarios#opaque-reference-access-token-support) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index 8e6083b757e4..a628552b7279 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -1465,215 +1465,6 @@ At this point, Razor components can adopt [role-based and policy-based authoriza * Security groups appear in `groups` claims, one claim per group. The security group GUIDs appear in the Azure portal when you create a security group and are listed when selecting **Identity** > **Overview** > **Groups** > **View**. * Built-in ME-ID administrator roles appear in `wids` claims, one claim per role. The `wids` claim with a value of `b79fbf4d-3ef9-4689-8143-76b194e85509` is always sent by ME-ID for non-guest accounts of the tenant and doesn't refer to an administrator role. Administrator role GUIDs (*role template IDs*) appear in the Azure portal when selecting **Roles & admins**, followed by the ellipsis (**…**) > **Description** for the listed role. The role template IDs are also listed in [Microsoft Entra built-in roles (Entra documentation)](/entra/identity/role-based-access-control/permissions-reference). -## Opaque (reference) access token support - -*The following guidance requires an authentication server that supports opaque (reference) access tokens. Currently, Microsoft Entra doesn't support opaque access token validation.* - - supports opaque tokens because it doesn't perform access token validation when configured for Proof Key for Code Exchange (PKCE) authorization code flow. It relies on the ASP.NET Core server's HTTPS backchannel to the OIDC authentication service to obtain the ID token using the authorization code received when the user redirects back to the ASP.NET Core app after signing in. If the app is only required to log a user in with OIDC to get a valid authentication cookie, opaque access tokens are supported without modifying the app. - -A failure occurs only when the opaque token acquired by is passed to another service that attempts to validate it with . Unlike self-contained JWTs, opaque tokens require a request to an authorization server to validate their status and retrieve claims. To work around this limitation, either use a third-party API, such as the [Duende Introspection Authentication Handler](https://docs.duendesoftware.com/introspection/), or create a [custom `AuthenticationHandler`](xref:security/authentication/index#authentication-handler) to validate the token. - -> [!IMPORTANT] -> [Duende Software](https://duendesoftware.com/) isn't owned or controlled by Microsoft and might require you to pay a license fee for production use of the Duende Introspection Authentication Handler. - -The following and associated configuration and helper code is provided as a general approach, which might require further development to suit a specific authorization server's requirements. The following handler extracts the opaque token from the `Authorization` header for an HTTP call to an authorization server's introspection endpoint and creates an containing the user's claims. - -Calling an authorization server's introspection endpoint requires authentication. The following example relies on setting the client secret for authentication in the request's Authorization header (base64 encoded credentials) using the [Secret Manager tool](xref:security/app-secrets) for local development and testing. - -[!INCLUDE[](~/blazor/security/includes/secure-authentication-flows.md)] - -In the following handler, the authorization server's introspection endpoint client secret uses the configuration key `Authentication:Schemes:AuthServer:ClientSecret`. - -If the Blazor server project hasn't been initialized for the Secret Manager tool, use a command shell, such as the Developer PowerShell command shell in Visual Studio, to execute the following command. Before executing the command, change the directory with the `cd` command to the server project's directory. The command establishes a user secrets identifier (`` in the server app's project file): - -```dotnetcli -dotnet user-secrets init -``` - -Execute the following command to set the client secret for the authorization server. The `{SECRET}` placeholder is the client secret: - -```dotnetcli -dotnet user-secrets set "Authentication:Schemes:AuthServer:ClientSecret" "{SECRET}" -``` - -If using Visual Studio, you can confirm the secret is set by right-clicking the server project in **Solution Explorer** and selecting **Manage User Secrets**. - -`Extensions/HttpContextExtensions.cs`: - -```csharp -namespace MinimalApiJwt.Extensions; - -public static class HttpContextExtensions -{ - public static string? ExtractBearerToken(this HttpRequest request) - { - var authorizationHeader = request.Headers["Authorization"].ToString(); - - if (!string.IsNullOrEmpty(authorizationHeader) && - authorizationHeader.StartsWith("Bearer ", - StringComparison.OrdinalIgnoreCase)) - { - var token = authorizationHeader["Bearer ".Length..].Trim(); - - if (!string.IsNullOrEmpty(token)) - { - return token; - } - } - - return null; - } -} -``` - -`Authentication/OpaqueTokenAuthenticationOptions.cs`: - -```csharp -using Microsoft.AspNetCore.Authentication; - -namespace MinimalApiJwt.Authentication; - -public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions -{ - public const string DefaultScheme = "OpaqueTokenAuthentication"; - public string? IntrospectionEndpoint { get; set; } - public string? ClientId { get; set; } -} -``` - -`Authentication/OpaqueTokenAuthenticationHandler.cs`: - -```csharp -using System.Net.Http.Headers; -using System.Security.Claims; -using System.Text.Encodings.Web; -using System.Text.Json; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Options; -using MinimalApiJwt.Extensions; - -namespace MinimalApiJwt.Authentication; - -public class OpaqueTokenAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder, - IConfiguration config, - IHttpClientFactory httpClientFactory) - : AuthenticationHandler(options, logger, encoder) -{ - protected override async Task HandleAuthenticateAsync() - { - var opaqueToken = Request.ExtractBearerToken(); - - if (opaqueToken is null) - { - var failedResult = AuthenticateResult.Fail( - "Bearer token not found in Authorization header."); - return failedResult; - } - - /* - The following example attempts to validate the opaque - (reference) access token. - - An HTTP call is made to the authorization server's introspection - endpoint with the token and the API's credentials. The response - is processed to determine if the token is valid. - - If the token is valid, an AuthenticationTicket is created - containing the user's claims. - - If the token is invalid, a failed authorization result is - returned. - */ - - var introspectionUri = options.IntrospectionEndpoint; - var clientId = options.ClientId; - var clientSecret = config["Authentication:Schemes:AuthServer:ClientSecret"]; - - using var client = httpClientFactory.CreateClient(); - - // Set the Authorization header (base64 encoded credentials) - var authString = Convert.ToBase64String( - System.Text.Encoding.ASCII.GetBytes($"{clientId}:{clientSecret}")); - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Basic", authString); - - // Prepare the form-encoded body containing the token - var content = new FormUrlEncodedContent( - [ - new KeyValuePair("token", opaqueToken) - // NOTE: Some servers require "token_type_hint", for example - // set to "access_token" - ]); - - // Post to the introspection endpoint - var response = await client.PostAsync(introspectionUri, content); - - if (!response.IsSuccessStatusCode) - { - var failedResult = AuthenticateResult.Fail( - "Introspection endpoint failure."); - - return failedResult; - } - - // Parse the JSON response - var responseString = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(responseString); - - // The 'active' property determines if the token is valid and not expired - var tokenIsValid = doc.RootElement.GetProperty("active").GetBoolean(); - - if (tokenIsValid) - { - // TODO: Replace the '{USER ID}' placeholder with extracted claim value - // from the token introspection response - var claims = new[] { new Claim(ClaimTypes.Name, "{USER ID}") }; - var identity = new ClaimsIdentity(claims, - OpaqueTokenAuthenticationOptions.DefaultScheme); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, - OpaqueTokenAuthenticationOptions.DefaultScheme); - - var result = AuthenticateResult.Success(ticket); - - return result; - } - else - { - var failedResult = AuthenticateResult.Fail("Bearer token invalid."); - - return failedResult; - } - } -} -``` - -In the `Program` file: - -```csharp -builder.Services.AddHttpClient(); -builder.Services.AddAuthentication() - .AddScheme( - OpaqueTokenAuthenticationOptions.DefaultScheme, - options => - { - options.IntrospectionEndpoint = "{AUTH SERVER INTROSPECTION URI}"; - options.ClientId = "{API CLIENT ID}"; - }); -``` - -The preceding example's placeholders: - -* `{AUTH SERVER INTROSPECTION URI}`: Authentication server's introspection URI -* `{API CLIENT ID}`: API Client ID - -Values for the authentication server introspection URI (`{AUTH SERVER INTROSPECTION URI}`) and the API client ID (`{API CLIENT ID}`) can be supplied from app settings or any other configuration source. - -Built-in opaque access token support is under consideration for a future release of .NET. For more information, see [Opaque - reference token validation (`dotnet/aspnetcore` #46026)](https://github.com/dotnet/aspnetcore/issues/46026). - ## Alternative: Duende Access Token Management In the sample app, a custom cookie refresher (`CookieOidcRefresher.cs`) implementation is used to perform automatic non-interactive token refresh. An alternative solution can be found in the open source [`Duende.AccessTokenManagement.OpenIdConnect` package](https://docs.duendesoftware.com/accesstokenmanagement/web-apps/). @@ -1724,3 +1515,4 @@ We also recommend using a shared [Data Protection](xref:security/data-protection * [Refresh token during http request in Blazor Interactive Server with OIDC (`dotnet/aspnetcore` #55213)](https://github.com/dotnet/aspnetcore/issues/55213) * [Secure data in Blazor Web Apps with Interactive Auto rendering](xref:blazor/security/index#secure-data-in-blazor-web-apps-with-interactive-auto-rendering) * [How to access an `AuthenticationStateProvider` from a `DelegatingHandler`](xref:blazor/security/additional-scenarios#access-authenticationstateprovider-in-outgoing-request-middleware) +* [Opaque (reference) access token support](xref:blazor/security/additional-scenarios#opaque-reference-access-token-support) diff --git a/aspnetcore/blazor/security/index.md b/aspnetcore/blazor/security/index.md index ce0a1f516a15..58366c7c59fe 100644 --- a/aspnetcore/blazor/security/index.md +++ b/aspnetcore/blazor/security/index.md @@ -1708,6 +1708,7 @@ PII refers any information relating to an identified or identifiable natural per * [Build a custom version of the Authentication.MSAL JavaScript library](xref:blazor/security/webassembly/additional-scenarios#build-a-custom-version-of-the-authenticationmsal-javascript-library) * [Awesome Blazor: Authentication](https://github.com/AdrienTorris/awesome-blazor#authentication) community sample links * +* [Opaque (reference) access token support](xref:blazor/security/additional-scenarios#opaque-reference-access-token-support) :::moniker-end @@ -1730,5 +1731,6 @@ PII refers any information relating to an identified or identifiable natural per * * [Build a custom version of the Authentication.MSAL JavaScript library](xref:blazor/security/webassembly/additional-scenarios#build-a-custom-version-of-the-authenticationmsal-javascript-library) * [Awesome Blazor: Authentication](https://github.com/AdrienTorris/awesome-blazor#authentication) community sample links +* [Opaque (reference) access token support](xref:blazor/security/additional-scenarios#opaque-reference-access-token-support) :::moniker-end From 263c054e1da907020b0765ad4923a75eff2bce95 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Tue, 19 May 2026 09:41:10 -0400 Subject: [PATCH 14/22] Update Aspire cross-links --- aspnetcore/blazor/security/blazor-web-app-with-entra.md | 2 +- aspnetcore/blazor/security/blazor-web-app-with-oidc.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-entra.md b/aspnetcore/blazor/security/blazor-web-app-with-entra.md index 098f02d90753..5343274dfa8d 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-entra.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-entra.md @@ -85,7 +85,7 @@ For more information on using Aspire and details on the `.AppHost` and `.Service Confirm that you've met the prerequisites for Aspire. For more information, see the *Prerequisites* section of [Quickstart: Build your first Aspire solution](/dotnet/aspire/get-started/build-your-first-aspire-app?tabs=visual-studio#prerequisites). -The sample app only configures an insecure HTTP launch profile (`http`) for use during development testing. For more information, including an example of insecure and secure launch settings profiles, see [Allow unsecure transport in Aspire (Aspire documentation)](/dotnet/aspire/troubleshooting/allow-unsecure-transport). +The sample app only configures an insecure HTTP launch profile (`http`) for use during development testing. For more information on Aspire tooling security, see [Aspire dashboard security considerations: Aspire tooling (Aspire documentation)](https://aspire.dev/dashboard/security-considerations/#aspire-tooling). ## Server-side Blazor Web App project (`BlazorWebAppEntra`) diff --git a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md index a628552b7279..d4d98bc616da 100644 --- a/aspnetcore/blazor/security/blazor-web-app-with-oidc.md +++ b/aspnetcore/blazor/security/blazor-web-app-with-oidc.md @@ -126,7 +126,7 @@ For more information on using Aspire and details on the `.AppHost` and `.Service Confirm that you've met the prerequisites for Aspire. For more information, see the *Prerequisites* section of [Quickstart: Build your first Aspire solution](/dotnet/aspire/get-started/build-your-first-aspire-app?tabs=visual-studio#prerequisites). -The sample app only configures an insecure HTTP launch profile (`http`) for use during development testing. For more information, including an example of insecure and secure launch settings profiles, see [Allow unsecure transport in Aspire (Aspire documentation)](/dotnet/aspire/troubleshooting/allow-unsecure-transport). +The sample app only configures an insecure HTTP launch profile (`http`) for use during development testing. For more information on Aspire tooling security, see [Aspire dashboard security considerations: Aspire tooling (Aspire documentation)](https://aspire.dev/dashboard/security-considerations/#aspire-tooling). ## `MinimalApiJwt` project From 9132529d60ae842bd5496e03f210951bb034fb69 Mon Sep 17 00:00:00 2001 From: Luke Latham <1622880+guardrex@users.noreply.github.com> Date: Tue, 26 May 2026 06:54:24 -0400 Subject: [PATCH 15/22] Apply suggestions from code review Co-authored-by: Stephen Halter --- .../blazor/security/additional-scenarios.md | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/aspnetcore/blazor/security/additional-scenarios.md b/aspnetcore/blazor/security/additional-scenarios.md index a73cac08e125..04f11ff1e45a 100644 --- a/aspnetcore/blazor/security/additional-scenarios.md +++ b/aspnetcore/blazor/security/additional-scenarios.md @@ -1066,7 +1066,10 @@ builder.Services.AddHttpClient("HttpMessageHandler") ## Opaque (reference) access token support -*The following guidance requires an authentication server that supports opaque (reference) access tokens. Currently, Microsoft Entra doesn't support opaque access token validation.* +> [!NOTE] +> The following guidance requires an authentication server that supports opaque (reference) access tokens. Currently, Microsoft Entra doesn't support opaque access token validation. Keycloak and Okta issue JWT access tokens by default; the handler still works against them because it relies only on RFC 7662 introspection, but "opaque" in this section describes how the client treats the token rather than how the server mints it. Duende IdentityServer issues true opaque reference tokens out of the box. +> +> When testing this pattern against Keycloak specifically, the API's introspection client must be a different OIDC client than the one that issued the user's access token. Introspecting a token using the client that minted it returns `{"active": false}`. supports opaque tokens because it doesn't perform access token validation when configured for Proof Key for Code Exchange (PKCE) authorization code flow. It relies on the ASP.NET Core server's HTTPS backchannel to the OIDC authentication service to obtain the ID token using the authorization code received when the user redirects back to the ASP.NET Core app after signing in. If the app is only required to log a user in with OIDC to get a valid authentication cookie, opaque access tokens are supported without modifying the app. @@ -1137,6 +1140,7 @@ public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions public const string DefaultScheme = "OpaqueTokenAuthentication"; public string? IntrospectionEndpoint { get; set; } public string? ClientId { get; set; } + public string? ClientSecret { get; set; } } ``` @@ -1187,9 +1191,9 @@ public class OpaqueTokenAuthenticationHandler( returned. */ - var introspectionUri = options.IntrospectionEndpoint; - var clientId = options.ClientId; - var clientSecret = config["Authentication:Schemes:AuthServer:ClientSecret"]; + var introspectionUri = Options.IntrospectionEndpoint; + var clientId = Options.ClientId; + var clientSecret = config["Authentication:Schemes:OpaqueTokenAuthentication:ClientSecret"]; using var client = httpClientFactory.CreateClient(); @@ -1227,9 +1231,44 @@ public class OpaqueTokenAuthenticationHandler( if (tokenIsValid) { - // TODO: Replace the '{USER ID}' placeholder with extracted claim value - // from the token introspection response - var claims = new[] { new Claim(ClaimTypes.Name, "{USER ID}") }; + // Map standard introspection response fields onto claims. + // Field names below match what Keycloak, Duende IdentityServer, + // Auth0, and Okta return; adjust the role source for your provider. + var claims = new List(); + + string? Get(string name) => + doc.RootElement.TryGetProperty(name, out var v) && + v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + var sub = Get("sub"); + var username = Get("preferred_username") ?? Get("username") ?? sub; + + if (sub is not null) claims.Add(new Claim(ClaimTypes.NameIdentifier, sub)); + if (username is not null) claims.Add(new Claim(ClaimTypes.Name, username)); + if (Get("email") is { } email) claims.Add(new Claim(ClaimTypes.Email, email)); + if ((Get("client_id") ?? Get("azp")) is { } cid) + claims.Add(new Claim("client_id", cid)); + if (Get("scope") is { } scope) + foreach (var s in scope.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + claims.Add(new Claim("scope", s)); + + // Keycloak surfaces realm roles under realm_access.roles. + // Duende/IdentityServer uses a flat "role" claim; Auth0 uses a + // configurable custom claim. Adjust for your authorization server. + if (doc.RootElement.TryGetProperty("realm_access", out var ra) && + ra.ValueKind == JsonValueKind.Object && + ra.TryGetProperty("roles", out var roles) && + roles.ValueKind == JsonValueKind.Array) + { + foreach (var r in roles.EnumerateArray()) + if (r.ValueKind == JsonValueKind.String) + claims.Add(new Claim(ClaimTypes.Role, r.GetString()!)); + } + + var identity = new ClaimsIdentity(claims, + OpaqueTokenAuthenticationOptions.DefaultScheme, + nameType: ClaimTypes.Name, + roleType: ClaimTypes.Role); var identity = new ClaimsIdentity(claims, OpaqueTokenAuthenticationOptions.DefaultScheme); var principal = new ClaimsPrincipal(identity); @@ -1267,7 +1306,7 @@ builder.Services.AddAuthentication() The preceding example's placeholders: * `{AUTH SERVER INTROSPECTION URI}`: Authentication server's introspection URI -* `{API CLIENT ID}`: API Client ID +* `{API CLIENT ID}`: API client ID Values for the authentication server introspection URI (`{AUTH SERVER INTROSPECTION URI}`) and the API client ID (`{API CLIENT ID}`) can be supplied from app settings or any other configuration source. From 8c61967a74db4615e1232f6c601565c79b87a0bd Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Tue, 26 May 2026 08:38:53 -0400 Subject: [PATCH 16/22] Updates --- .../blazor/security/additional-scenarios.md | 60 ++++++++++--------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/aspnetcore/blazor/security/additional-scenarios.md b/aspnetcore/blazor/security/additional-scenarios.md index 04f11ff1e45a..4486ee3d502f 100644 --- a/aspnetcore/blazor/security/additional-scenarios.md +++ b/aspnetcore/blazor/security/additional-scenarios.md @@ -1066,17 +1066,23 @@ builder.Services.AddHttpClient("HttpMessageHandler") ## Opaque (reference) access token support +The guidance in this section explains how to implement opaque (reference) access token support, which offers the following advantages over JSON Web Tokens (JWTs): + +* Strict revocation: Invalidate access tokens at any time before they naturally expire. +* Token size limits: Store a large number of user claims in the token to avoid a prohibitively large JWT. +* Security: Prevent API consumers or third parties from reading access token claims. + > [!NOTE] -> The following guidance requires an authentication server that supports opaque (reference) access tokens. Currently, Microsoft Entra doesn't support opaque access token validation. Keycloak and Okta issue JWT access tokens by default; the handler still works against them because it relies only on RFC 7662 introspection, but "opaque" in this section describes how the client treats the token rather than how the server mints it. Duende IdentityServer issues true opaque reference tokens out of the box. +> The following guidance requires an authentication server that supports opaque (reference) access tokens. Currently, Microsoft Entra doesn't support opaque access token validation. [Keycloak](https://www.keycloak.org/) and [Okta](https://developer.okta.com/code/) issue JWT access tokens by default. The opaque token handler in this section still works against Keycloak and Okta because it relies only on RFC 7662 introspection. "Opaque" in this section describes how the client treats the token rather than how the server mints it. Alternatively, [Duende IdentityServer](https://duendesoftware.com/products/identityserver) can be configured to only issue opaque tokens. > -> When testing this pattern against Keycloak specifically, the API's introspection client must be a different OIDC client than the one that issued the user's access token. Introspecting a token using the client that minted it returns `{"active": false}`. +> When testing this pattern against Keycloak, the API's introspection client must be a different OIDC client than the one that issued the user's access token. Introspecting a token using the client that minted it returns `{"active": false}`. supports opaque tokens because it doesn't perform access token validation when configured for Proof Key for Code Exchange (PKCE) authorization code flow. It relies on the ASP.NET Core server's HTTPS backchannel to the OIDC authentication service to obtain the ID token using the authorization code received when the user redirects back to the ASP.NET Core app after signing in. If the app is only required to log a user in with OIDC to get a valid authentication cookie, opaque access tokens are supported without modifying the app. A failure occurs only when the opaque token acquired by is passed to another service that attempts to validate it with . Unlike self-contained JWTs, opaque tokens require a request to an authorization server to validate their status and retrieve claims. To work around this limitation, either use a third-party API, such as the [Duende Introspection Authentication Handler](https://docs.duendesoftware.com/introspection/), or create a [custom `AuthenticationHandler`](xref:security/authentication/index#authentication-handler) to validate the token. > [!IMPORTANT] -> [Duende Software](https://duendesoftware.com/) isn't owned or controlled by Microsoft and might require you to pay a license fee for production use of the Duende Introspection Authentication Handler. +> [Duende Software](https://duendesoftware.com/) and [Okta](https://www.okta.com) aren't owned or controlled by Microsoft and might require you to pay a license fee for production use of their services and libraries. The following and associated configuration and helper code is provided as a general approach, which might require further development to suit a specific authorization server's requirements. The following handler extracts the opaque token from the `Authorization` header for an HTTP call to an authorization server's introspection endpoint and creates an containing the user's claims. @@ -1084,7 +1090,7 @@ Calling an authorization server's introspection endpoint requires authentication [!INCLUDE[](~/blazor/security/includes/secure-authentication-flows.md)] -In the following handler, the authorization server's introspection endpoint client secret uses the configuration key `Authentication:Schemes:AuthServer:ClientSecret`. +In the following handler, the authorization server's introspection endpoint client secret uses the configuration key `Authentication:Schemes:OpaqueTokenAuthentication:ClientSecret`. If the Blazor server project hasn't been initialized for the Secret Manager tool, use a command shell, such as the Developer PowerShell command shell in Visual Studio, to execute the following command. Before executing the command, change the directory with the `cd` command to the server project's directory. The command establishes a user secrets identifier (`` in the server app's project file): @@ -1095,7 +1101,7 @@ dotnet user-secrets init Execute the following command to set the client secret for the authorization server. The `{SECRET}` placeholder is the client secret: ```dotnetcli -dotnet user-secrets set "Authentication:Schemes:AuthServer:ClientSecret" "{SECRET}" +dotnet user-secrets set "Authentication:Schemes:OpaqueTokenAuthentication:ClientSecret" "{SECRET}" ``` If using Visual Studio, you can confirm the secret is set by right-clicking the server project in **Solution Explorer** and selecting **Manage User Secrets**. @@ -1109,7 +1115,7 @@ public static class HttpContextExtensions { public static string? ExtractBearerToken(this HttpRequest request) { - var authorizationHeader = request.Headers["Authorization"].ToString(); + var authorizationHeader = request.Headers.Authorization.ToString(); if (!string.IsNullOrEmpty(authorizationHeader) && authorizationHeader.StartsWith("Bearer ", @@ -1144,6 +1150,17 @@ public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions } ``` +The following handler attempts to validate an opaque (reference) access token. An HTTP call is made to the authorization server's introspection endpoint with the token and the API's credentials. The response is processed to determine if the token is valid: + +* If the token is valid, an is created containing the user's claims. +* If the token is invalid, a failed authorization result is returned. + +The handler's options (`Options`) is an instance of `OpaqueTokenAuthenticationOptions` provided by the base type, which is configured in the app's `Program` file with the authorization server's introspection endpoint and the API's client ID. The API's client secret is provided by the Secret Manager tool during development. + +`IOptionsMonitor` (`optionsMonitor`) isn't used, but it could be used to support dynamic configuration changes at runtime. + +For the request's content in , some servers require a token type hint (`token_type_hint`). For example, the required value might be `access_token`. See your authentication server's documentation for details. + `Authentication/OpaqueTokenAuthenticationHandler.cs`: ```csharp @@ -1158,12 +1175,12 @@ using MinimalApiJwt.Extensions; namespace MinimalApiJwt.Authentication; public class OpaqueTokenAuthenticationHandler( - IOptionsMonitor options, + IOptionsMonitor optionsMonitor, ILoggerFactory logger, UrlEncoder encoder, - IConfiguration config, IHttpClientFactory httpClientFactory) - : AuthenticationHandler(options, logger, encoder) + : AuthenticationHandler(optionsMonitor, + logger, encoder) { protected override async Task HandleAuthenticateAsync() { @@ -1176,24 +1193,9 @@ public class OpaqueTokenAuthenticationHandler( return failedResult; } - /* - The following example attempts to validate the opaque - (reference) access token. - - An HTTP call is made to the authorization server's introspection - endpoint with the token and the API's credentials. The response - is processed to determine if the token is valid. - - If the token is valid, an AuthenticationTicket is created - containing the user's claims. - - If the token is invalid, a failed authorization result is - returned. - */ - var introspectionUri = Options.IntrospectionEndpoint; var clientId = Options.ClientId; - var clientSecret = config["Authentication:Schemes:OpaqueTokenAuthentication:ClientSecret"]; + var clientSecret = Options.ClientSecret; using var client = httpClientFactory.CreateClient(); @@ -1207,8 +1209,6 @@ public class OpaqueTokenAuthenticationHandler( var content = new FormUrlEncodedContent( [ new KeyValuePair("token", opaqueToken) - // NOTE: Some servers require "token_type_hint", for example - // set to "access_token" ]); // Post to the introspection endpoint @@ -1269,8 +1269,6 @@ public class OpaqueTokenAuthenticationHandler( OpaqueTokenAuthenticationOptions.DefaultScheme, nameType: ClaimTypes.Name, roleType: ClaimTypes.Role); - var identity = new ClaimsIdentity(claims, - OpaqueTokenAuthenticationOptions.DefaultScheme); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, OpaqueTokenAuthenticationOptions.DefaultScheme); @@ -1292,6 +1290,10 @@ public class OpaqueTokenAuthenticationHandler( In the `Program` file: ```csharp +using MinimalApiJwt.Authentication; + +... + builder.Services.AddHttpClient(); builder.Services.AddAuthentication() .AddScheme( From 2e9f2a43921aa829169e041c810d2682ba08ce83 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Tue, 26 May 2026 08:46:48 -0400 Subject: [PATCH 17/22] Updates --- aspnetcore/blazor/security/additional-scenarios.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aspnetcore/blazor/security/additional-scenarios.md b/aspnetcore/blazor/security/additional-scenarios.md index 4486ee3d502f..6dabcbd856b2 100644 --- a/aspnetcore/blazor/security/additional-scenarios.md +++ b/aspnetcore/blazor/security/additional-scenarios.md @@ -1075,7 +1075,7 @@ The guidance in this section explains how to implement opaque (reference) access > [!NOTE] > The following guidance requires an authentication server that supports opaque (reference) access tokens. Currently, Microsoft Entra doesn't support opaque access token validation. [Keycloak](https://www.keycloak.org/) and [Okta](https://developer.okta.com/code/) issue JWT access tokens by default. The opaque token handler in this section still works against Keycloak and Okta because it relies only on RFC 7662 introspection. "Opaque" in this section describes how the client treats the token rather than how the server mints it. Alternatively, [Duende IdentityServer](https://duendesoftware.com/products/identityserver) can be configured to only issue opaque tokens. > -> When testing this pattern against Keycloak, the API's introspection client must be a different OIDC client than the one that issued the user's access token. Introspecting a token using the client that minted it returns `{"active": false}`. +> When testing this pattern against Keycloak, the API's introspection client must be a different OIDC client than the one that issued the user's access token. Introspecting a token using the client that minted it returns `{"active": false}` with "`Access token JWT check failed`" in the server's log. This doesn't happen naturally for the following scenario because the Blazor Web App and the Minimal API (`MinimalApiJwt`) are separate clients. supports opaque tokens because it doesn't perform access token validation when configured for Proof Key for Code Exchange (PKCE) authorization code flow. It relies on the ASP.NET Core server's HTTPS backchannel to the OIDC authentication service to obtain the ID token using the authorization code received when the user redirects back to the ASP.NET Core app after signing in. If the app is only required to log a user in with OIDC to get a valid authentication cookie, opaque access tokens are supported without modifying the app. From 99796422e179165c6b9cc731989e780edbf26c3c Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Tue, 26 May 2026 09:03:09 -0400 Subject: [PATCH 18/22] Updates --- aspnetcore/blazor/security/additional-scenarios.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aspnetcore/blazor/security/additional-scenarios.md b/aspnetcore/blazor/security/additional-scenarios.md index 6dabcbd856b2..b8caeddf7ca2 100644 --- a/aspnetcore/blazor/security/additional-scenarios.md +++ b/aspnetcore/blazor/security/additional-scenarios.md @@ -1106,12 +1106,12 @@ dotnet user-secrets set "Authentication:Schemes:OpaqueTokenAuthentication:Client If using Visual Studio, you can confirm the secret is set by right-clicking the server project in **Solution Explorer** and selecting **Manage User Secrets**. -`Extensions/HttpContextExtensions.cs`: +`Extensions/HttpRequestExtensions.cs`: ```csharp namespace MinimalApiJwt.Extensions; -public static class HttpContextExtensions +public static class HttpRequestExtensions { public static string? ExtractBearerToken(this HttpRequest request) { From 9d52aef8ca04b86d7aaeb71aeb3aca17ff5dac66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 13:08:25 +0000 Subject: [PATCH 19/22] Add config guard clause to opaque token handler sample Agent-Logs-Url: https://github.com/dotnet/AspNetCore.Docs/sessions/d0e56126-9c6f-4bb1-a830-594eae2798c2 Co-authored-by: guardrex <1622880+guardrex@users.noreply.github.com> --- aspnetcore/blazor/security/additional-scenarios.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/aspnetcore/blazor/security/additional-scenarios.md b/aspnetcore/blazor/security/additional-scenarios.md index b8caeddf7ca2..635f2fb78568 100644 --- a/aspnetcore/blazor/security/additional-scenarios.md +++ b/aspnetcore/blazor/security/additional-scenarios.md @@ -1197,6 +1197,15 @@ public class OpaqueTokenAuthenticationHandler( var clientId = Options.ClientId; var clientSecret = Options.ClientSecret; + if (string.IsNullOrWhiteSpace(introspectionUri) || + string.IsNullOrWhiteSpace(clientId) || + string.IsNullOrWhiteSpace(clientSecret)) + { + var failedResult = AuthenticateResult.Fail( + "Opaque token authentication isn't fully configured."); + return failedResult; + } + using var client = httpClientFactory.CreateClient(); // Set the Authorization header (base64 encoded credentials) From 0e83a41a3c9b7b423d487d6f3732621e6d448f2d Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Tue, 26 May 2026 09:59:02 -0400 Subject: [PATCH 20/22] Updates --- .../blazor/security/additional-scenarios.md | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/aspnetcore/blazor/security/additional-scenarios.md b/aspnetcore/blazor/security/additional-scenarios.md index b8caeddf7ca2..e58d923f5d44 100644 --- a/aspnetcore/blazor/security/additional-scenarios.md +++ b/aspnetcore/blazor/security/additional-scenarios.md @@ -1090,7 +1090,7 @@ Calling an authorization server's introspection endpoint requires authentication [!INCLUDE[](~/blazor/security/includes/secure-authentication-flows.md)] -In the following handler, the authorization server's introspection endpoint client secret uses the configuration key `Authentication:Schemes:OpaqueTokenAuthentication:ClientSecret`. +In the following handler, the authorization server's introspection endpoint client secret uses the configuration key `Authentication:Schemes:OpaqueTokenAuthentication:ClientSecret`. For production apps, consider using *client assertions*. For more information, see [Confidential client assertions (Microsoft Entra documentation)](/entra/msal/dotnet/acquiring-tokens/web-apps-apis/confidential-client-assertions). If the Blazor server project hasn't been initialized for the Secret Manager tool, use a command shell, such as the Developer PowerShell command shell in Visual Studio, to execute the following command. Before executing the command, change the directory with the `cd` command to the server project's directory. The command establishes a user secrets identifier (`` in the server app's project file): @@ -1312,4 +1312,37 @@ The preceding example's placeholders: Values for the authentication server introspection URI (`{AUTH SERVER INTROSPECTION URI}`) and the API client ID (`{API CLIENT ID}`) can be supplied from app settings or any other configuration source. +Tokens are typically invalidated on a logout event using the revocation endpoint. The following example is a starting point for further development: + +```csharp +app.MapPost("/logout", + async ([FromForm] string? returnUrl, HttpContext context, + IHttpClientFactory httpClientFactory) => +{ + var accessToken = await context.GetTokenAsync("access_token"); + + if (!string.IsNullOrEmpty(accessToken)) + { + // Prepare the revocation request (RFC 7009) + var requestContent = + new FormUrlEncodedContent(new Dictionary + { + { "token", accessToken }, + { "token_type_hint", "access_token" }, + { "client_id", "your_client_id" }, + { "client_secret", "your_client_secret" } + }); + + // POST to the revocation endpoint + using var client = httpClientFactory.CreateClient(); + + await client.PostAsync( + "https://your-identity-server/connect/revocation", requestContent); + } + + TypedResults.SignOut(GetAuthProperties(returnUrl), + [CookieAuthenticationDefaults.AuthenticationScheme]); +}); +``` + Built-in opaque access token support is under consideration for a future release of .NET. For more information, see [Opaque - reference token validation (`dotnet/aspnetcore` #46026)](https://github.com/dotnet/aspnetcore/issues/46026). From 8fcd297bb4e7fa16950da13746c1d806a838bce3 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Tue, 26 May 2026 10:14:38 -0400 Subject: [PATCH 21/22] Updates --- .../blazor/security/additional-scenarios.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/aspnetcore/blazor/security/additional-scenarios.md b/aspnetcore/blazor/security/additional-scenarios.md index f4b2f169d221..3d2d090fc8d4 100644 --- a/aspnetcore/blazor/security/additional-scenarios.md +++ b/aspnetcore/blazor/security/additional-scenarios.md @@ -1333,20 +1333,19 @@ app.MapPost("/logout", if (!string.IsNullOrEmpty(accessToken)) { // Prepare the revocation request (RFC 7009) - var requestContent = + var content = new FormUrlEncodedContent(new Dictionary { { "token", accessToken }, { "token_type_hint", "access_token" }, - { "client_id", "your_client_id" }, - { "client_secret", "your_client_secret" } + { "client_id", "{API CLIENT ID}" }, + { "client_secret", "{CLIENT SECRET}" } }); // POST to the revocation endpoint using var client = httpClientFactory.CreateClient(); - await client.PostAsync( - "https://your-identity-server/connect/revocation", requestContent); + await client.PostAsync("{AUTH SERVER TOKEN REVOCATION URI}", content); } TypedResults.SignOut(GetAuthProperties(returnUrl), @@ -1354,4 +1353,10 @@ app.MapPost("/logout", }); ``` +The preceding example's placeholders: + +* `{AUTH SERVER TOKEN REVOCATION URI}`: The authentication server's token revocation URI. +* `{API CLIENT ID}`: The API client ID. +* `{CLIENT SECRET}`: The client secret obtained securely. + Built-in opaque access token support is under consideration for a future release of .NET. For more information, see [Opaque - reference token validation (`dotnet/aspnetcore` #46026)](https://github.com/dotnet/aspnetcore/issues/46026). From 164397112eb62d54395a42425f6ae55c33a40ef0 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Tue, 26 May 2026 10:26:11 -0400 Subject: [PATCH 22/22] Updates --- aspnetcore/blazor/security/additional-scenarios.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/aspnetcore/blazor/security/additional-scenarios.md b/aspnetcore/blazor/security/additional-scenarios.md index 3d2d090fc8d4..da5fa0d02b0e 100644 --- a/aspnetcore/blazor/security/additional-scenarios.md +++ b/aspnetcore/blazor/security/additional-scenarios.md @@ -1157,7 +1157,7 @@ The following handler attempts to validate an opaque (reference) access token. A The handler's options (`Options`) is an instance of `OpaqueTokenAuthenticationOptions` provided by the base type, which is configured in the app's `Program` file with the authorization server's introspection endpoint and the API's client ID. The API's client secret is provided by the Secret Manager tool during development. -`IOptionsMonitor` (`optionsMonitor`) isn't used, but it could be used to support dynamic configuration changes at runtime. +`IOptionsMonitor` (`optionsMonitor`) isn't used directly by the handler, but it could be used to support dynamic configuration changes at runtime. For the request's content in , some servers require a token type hint (`token_type_hint`). For example, the required value might be `access_token`. See your authentication server's documentation for details. @@ -1236,7 +1236,9 @@ public class OpaqueTokenAuthenticationHandler( using var doc = JsonDocument.Parse(responseString); // The 'active' property determines if the token is valid and not expired - var tokenIsValid = doc.RootElement.GetProperty("active").GetBoolean(); + var tokenIsValid = + doc.RootElement.TryGetProperty("active", out var activeProperty) && + activeProperty.ValueKind == JsonValueKind.True; if (tokenIsValid) { @@ -1359,4 +1361,6 @@ The preceding example's placeholders: * `{API CLIENT ID}`: The API client ID. * `{CLIENT SECRET}`: The client secret obtained securely. +In [Duende IdentityServer](https://duendesoftware.com/products/identityserver), tokens are revoked automatically by setting the `CoordinateLifetimeWithUserSession` client configuration property to `true`, which automatically cleans up associated tokens when a session ends. For more information, see [Session Cleanup and Logout (Duende documentation)](https://docs.duendesoftware.com/identityserver/ui/logout/session-cleanup/). + Built-in opaque access token support is under consideration for a future release of .NET. For more information, see [Opaque - reference token validation (`dotnet/aspnetcore` #46026)](https://github.com/dotnet/aspnetcore/issues/46026).