Opaque (reference) access token guidance#36588
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds comprehensive guidance for handling opaque (reference) access tokens in ASP.NET Core Blazor Web Apps with OIDC authentication, addressing issue #36422. The documentation explains when opaque tokens are supported by default and provides a starting-point implementation for scenarios requiring custom token validation.
Key Changes
- Explains that AddOpenIdConnect inherently supports opaque tokens for basic authentication scenarios without additional configuration
- Documents the limitation when opaque tokens need to be validated by services using AddJwtBearer
- Provides a custom AuthenticationHandler implementation as a starting point for developers who need to validate opaque tokens via introspection endpoints
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
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>
b071c5a to
8b16fd9
Compare
| var introspectionUri = options.IntrospectionEndpoint; | ||
| var clientId = options.ClientId; |
There was a problem hiding this comment.
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:
| 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).
| The preceding example's placeholders: | ||
|
|
||
| * `{AUTH SERVER INTROSPECTION URI}`: Authentication server's introspection URI | ||
| * `{API CLIENT ID}`: API Client ID |
There was a problem hiding this comment.
Nit. Sentence case per the style guide:
| * `{API CLIENT ID}`: API Client ID | |
| * `{API CLIENT ID}`: API client ID |
|
|
||
| var introspectionUri = options.IntrospectionEndpoint; | ||
| var clientId = options.ClientId; | ||
| var clientSecret = config["Authentication:Schemes:AuthServer:ClientSecret"]; |
There was a problem hiding this comment.
The scheme name in this config key (AuthServer) doesn't match OpaqueTokenAuthenticationOptions.DefaultScheme ("OpaqueTokenAuthentication"). The standard Authentication:Schemes:{SchemeName}:... convention would make this:
| 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.
| public class OpaqueTokenAuthenticationOptions : AuthenticationSchemeOptions | ||
| { | ||
| public const string DefaultScheme = "OpaqueTokenAuthentication"; | ||
| public string? IntrospectionEndpoint { get; set; } | ||
| public string? ClientId { get; set; } | ||
| } |
There was a problem hiding this comment.
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.
| 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 configparameter from the handler's constructor. - Replace
config["Authentication:Schemes:AuthServer:ClientSecret"]withOptions.ClientSecret. - Update the
dotnet user-secrets setcommand to useAuthentication:Schemes:OpaqueTokenAuthentication:ClientSecret.
| // TODO: Replace the '{USER ID}' placeholder with extracted claim value | ||
| // from the token introspection response | ||
| var claims = new[] { new Claim(ClaimTypes.Name, "{USER ID}") }; |
There was a problem hiding this comment.
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:
| // 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 |
|
|
||
| ## 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.* |
There was a problem hiding this comment.
Based on testing the sample against Keycloak:
-
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.
-
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 (BlazorWebAppOidcandMinimalApiJwtare 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::
| *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}`. |
Fixes #36422
cc: @mikekistler
Stephen ... I hacked some nasty 🦖 code 🙈😆 with the help of AI to give you an idea of what I have in mind for the bits that call the auth server to validate the token.
Apparently, Entra doesn't support opaque access token validation, per this MS answer as of 2023 and a local test that I just ran here with Entra.
I originally had this in the BWA-OIDC article, but it's more general than that, so I just moved it to the additional scenarios article and cross-linked to it there from a few spots.
Internal previews