Skip to content
209 changes: 209 additions & 0 deletions aspnetcore/blazor/security/additional-scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on testing the sample against Keycloak:

  1. Keycloak and Okta also issue JWT access tokens by default — there isn't a first-class "opaque" toggle. The handler still works against them (introspection doesn't care whether the token is opaque or JWT), but readers reaching for this section because they're using Keycloak/Okta should know that "opaque" here refers to how the client treats the token, not how the server mints it. Duende IdentityServer is the main implementation in the .NET ecosystem that issues truly opaque reference tokens out of the box.

  2. Keycloak-specific footgun, worth a sentence: when testing introspection against Keycloak, the API's introspection client must be different from the OIDC client that issued the user's access token. If they're the same client, Keycloak returns {"active": false} with "Access token JWT check failed" in the server log, and the reader will think the handler is broken. In the documented Blazor Web App scenario this doesn't happen naturally (BlazorWebAppOidc and MinimalApiJwt are separate clients), but a one-liner saves the next person an hour.

Also, italicized prose for a caveat reads a bit awkwardly here. Consider a NOTE block so it stands out the same way the IMPORTANT block below does::

Suggested change
*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}`.


<xref:Microsoft.Extensions.DependencyInjection.OpenIdConnectExtensions.AddOpenIdConnect%2A> 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 <xref:Microsoft.Extensions.DependencyInjection.OpenIdConnectExtensions.AddOpenIdConnect%2A> is passed to another service that attempts to validate it with <xref:Microsoft.Extensions.DependencyInjection.JwtBearerExtensions.AddJwtBearer%2A>. 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 <xref:Microsoft.AspNetCore.Authentication.AuthenticationHandler%601> 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 <xref:Microsoft.AspNetCore.Authentication.AuthenticationTicket> 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 (`<UserSecretsId>` 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; }
}
Comment on lines +1135 to +1140
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the Authentication:Schemes:AuthServer:ClientSecret comment further down: the cleaner pattern is to add ClientSecret to OpaqueTokenAuthenticationOptions and let the framework's normal Authentication:Schemes:{SchemeName}:* binding pick it up. Then the handler doesn't need an IConfiguration dependency at all and the config key in the docs and the dotnet user-secrets set command would automatically match the scheme name. Optional, but worth considering since it eliminates the naming-inconsistency surface area entirely.

Suggested change
public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions
{
public const string DefaultScheme = "OpaqueTokenAuthentication";
public string? IntrospectionEndpoint { get; set; }
public string? ClientId { get; set; }
}
public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions
{
public const string DefaultScheme = "OpaqueTokenAuthentication";
public string? IntrospectionEndpoint { get; set; }
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
}

If you apply this, also:

  • Drop the IConfiguration config parameter from the handler's constructor.
  • Replace config["Authentication:Schemes:AuthServer:ClientSecret"] with Options.ClientSecret.
  • Update the dotnet user-secrets set command to use Authentication:Schemes:OpaqueTokenAuthentication:ClientSecret.

```

`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<OpaqueTokenAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration config,
IHttpClientFactory httpClientFactory)
: AuthenticationHandler<OpaqueTokenAuthenticationOptions>(options, logger, encoder)
{
protected override async Task<AuthenticateResult> 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;
Comment on lines +1190 to +1191
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't compile. options is the IOptionsMonitor<OpaqueTokenAuthenticationOptions> constructor parameter, which doesn't have IntrospectionEndpoint/ClientId properties. Use the base class's Options property (capital O) instead, which exposes the resolved options for the current scheme:

Suggested change
var introspectionUri = options.IntrospectionEndpoint;
var clientId = options.ClientId;
var introspectionUri = Options.IntrospectionEndpoint;
var clientId = Options.ClientId;

If you keep this fix, the options constructor parameter is no longer referenced directly and the using Microsoft.Extensions.Options; line can stay (still needed for the generic constraint passed to the base type).

var clientSecret = config["Authentication:Schemes:AuthServer:ClientSecret"];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scheme name in this config key (AuthServer) doesn't match OpaqueTokenAuthenticationOptions.DefaultScheme ("OpaqueTokenAuthentication"). The standard Authentication:Schemes:{SchemeName}:... convention would make this:

Suggested change
var clientSecret = config["Authentication:Schemes:AuthServer:ClientSecret"];
var clientSecret = config["Authentication:Schemes:OpaqueTokenAuthentication:ClientSecret"];

If you change this, also update the dotnet user-secrets set command on line 1095 and the explanatory text on line 1084 to use the same key.


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<string, string>("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}") };
Comment on lines +1230 to +1232
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the most important issue with the section. I stood up Keycloak and ran the sample end-to-end with a real access token. With the handler as written, the call succeeds (IsAuthenticated == true) but produces a ClaimsPrincipal with Name = "{USER ID}", no NameIdentifier, no email, and no roles. The // TODO is buried inside a code block and very easy to miss — a reader who copies this verbatim ships authentication that looks like it works but exposes no usable user information.

Please replace the placeholder with a real mapping from the standard RFC 7662 introspection fields. Suggested:

Suggested change
// 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<Claim>();
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);

Side-by-side test result against the same Keycloak token (user alice in roles reader, writer):

Doc handler (verbatim) With the mapping above
User.Identity.Name "{USER ID}" "alice"
User.IsInRole("reader") false true
Claim count 1 9

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, OpaqueTokenAuthenticationHandler>(
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. Sentence case per the style guide:

Suggested change
* `{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.

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).
3 changes: 2 additions & 1 deletion aspnetcore/blazor/security/blazor-web-app-with-entra.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -1175,3 +1175,4 @@ We also recommend using a shared [Data Protection](xref:security/data-protection
* <xref:security/data-protection/configuration/overview>
* <xref:security/data-protection/implementation/key-storage-providers>
* <xref:security/data-protection/implementation/key-encryption-at-rest>
* [Opaque (reference) access token support](xref:blazor/security/additional-scenarios#opaque-reference-access-token-support)
3 changes: 2 additions & 1 deletion aspnetcore/blazor/security/blazor-web-app-with-oidc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1515,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)
2 changes: 2 additions & 0 deletions aspnetcore/blazor/security/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <xref:blazor/hybrid/security/index>
* [Opaque (reference) access token support](xref:blazor/security/additional-scenarios#opaque-reference-access-token-support)

:::moniker-end

Expand All @@ -1730,5 +1731,6 @@ PII refers any information relating to an identified or identifiable natural per
* <xref:security/authentication/windowsauth>
* [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
Loading