adllm Insights logo adllm Insights logo

Implementing a Custom AuthenticationStateProvider in Blazor Server for OAuth 2.0 PKCE with a Legacy IdP

Published on by The adllm Team. Last modified: . Tags: Blazor Server AuthenticationStateProvider OAuth 2.0 PKCE Legacy IdP C# .NET Security Authentication

Blazor Server applications offer a robust platform for building interactive web UIs with .NET. When it comes to authentication, while ASP.NET Core provides excellent built-in support for standards like OpenID Connect (OIDC), real-world scenarios often involve integrating with older, legacy Identity Providers (IdPs). These IdPs might not fully adhere to modern specifications, lack features like discovery endpoints, or require specific custom parameter handling, making the standard middleware challenging to use directly.

This article provides a comprehensive guide to implementing a custom AuthenticationStateProvider in a Blazor Server application to handle an OAuth 2.0 Authorization Code Flow with PKCE (Proof Key for Code Exchange). This approach grants you the fine-grained control necessary to interact successfully with legacy IdPs that may have non-standard behaviors, ensuring secure and effective authentication.

We will cover the creation of the PKCE parameters, redirection to the IdP, handling the callback, managing tokens, and populating the user’s ClaimsPrincipal. The focus will be on the flexibility a custom solution provides when dealing with the idiosyncrasies of older authentication systems.

Core Concepts Refresher

Before diving into the implementation, let’s briefly revisit the key components:

  • Blazor Server: Your application logic runs on the server, with UI updates pushed to the client via SignalR. Authentication state is managed server-side. For more details, see Blazor hosting models.
  • AuthenticationStateProvider: A Blazor service that provides the current user’s authentication state (ClaimsPrincipal). Customizing it allows defining how Blazor understands who is logged in. Learn more from the ASP.NET Core documentation on AuthenticationStateProvider.
  • OAuth 2.0 Authorization Code Flow: A standard flow where the user authenticates with an IdP, which then provides an authorization code to your application. Your application exchanges this code for an access token (and potentially a refresh token).
  • PKCE (Proof Key for Code Exchange): An extension to the Authorization Code Flow that enhances security by preventing authorization code interception attacks. It involves a client-generated code_verifier and a transformed code_challenge.
  • Legacy IdP: An older Identity Provider that might not fully support modern standards (e.g., OIDC discovery, standard PKCE parameter names, standard claim types), requiring a more tailored client implementation.
  • ClaimsPrincipal: Represents the authenticated user, containing a collection of claims. See the ClaimsPrincipal Class documentation.

Project Setup and Prerequisites

Ensure you have a Blazor Server project. You’ll primarily be working with C# and ASP.NET Core services. For communication with the IdP, HttpClient will be used, typically managed via IHttpClientFactory.

Add necessary NuGet packages if not already present (though most are part of the default Blazor Server template):

You will also need configuration details for your legacy IdP. Store these securely, for example, in appsettings.json for development, as described in the ASP.NET Core Configuration documentation:

1
2
3
4
5
6
7
8
9
{
  "LegacyIdP": {
    "ClientId": "your-client-id",
    "AuthorizationEndpoint": "https://legacy-idp.com/auth",
    "TokenEndpoint": "https://legacy-idp.com/token",
    "RedirectUri": "https://localhost:7001/auth/callback", // Adjust port
    "Scope": "openid profile email custom_scope"
  }
}

1. PKCE Helper Utilities

PKCE requires generating a code_verifier (a random string) and a code_challenge (typically a SHA256 hash of the verifier, Base64 URL-encoded).

Create a utility class for this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
using System;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.WebUtilities; // For WebEncoders

public static class PkceUtil
{
    public static string GenerateCodeVerifier(int length = 32)
    {
        if (length < 32 || length > 96) // Per RFC 7636, min 43, max 128 chars
        {                                // for verifier after base64url encoding
                                         // Raw byte array length: 32 bytes -> 43 chars
                                         // 96 bytes -> 128 chars
            throw new ArgumentOutOfRangeException(
                nameof(length), "Length for raw bytes must be between 32 and 96.");
        }
        var randomNumber = new byte[length];
        // Use RandomNumberGenerator for cryptographically strong random numbers
        // See: https://learn.microsoft.com/dotnet/api/system.security.cryptography.randomnumbergenerator
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomNumber);
        return WebEncoders.Base64UrlEncode(randomNumber);
    }

    public static string GenerateCodeChallenge(string codeVerifier)
    {
        if (string.IsNullOrEmpty(codeVerifier))
        {
            throw new ArgumentNullException(nameof(codeVerifier));
        }

        // Use SHA256 for the challenge
        // See: https://learn.microsoft.com/dotnet/api/system.security.cryptography.sha256
        using var sha256 = SHA256.Create();
        var challengeBytes = sha256.ComputeHash(
            Encoding.UTF8.GetBytes(codeVerifier)
        );
        // Encode using Base64 URL encoding without padding
        // See: https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.webutilities.webencoders
        return WebEncoders.Base64UrlEncode(challengeBytes);
    }
}

This class provides methods to create the necessary PKCE parameters. The code_verifier will need to be stored temporarily (server-side) between initiating the login and handling the callback.

2. Token Storage Service

In Blazor Server, tokens are sensitive and must be stored securely on the server, associated with the user’s circuit/session. For simplicity, we’ll create a scoped service to hold tokens. In a production scenario, you might consider more robust storage like IDistributedCache if sessions can span multiple server instances.

1
2
3
4
5
6
public class UserTokenStore
{
    public string? AccessToken { get; set; }
    public string? RefreshToken { get; set; } // Optional
    public DateTimeOffset AccessTokenExpiresAt { get; set; }
}

This service will be registered as scoped in Program.cs. Each user circuit will get its own instance.

3. Implementing the Custom AuthenticationStateProvider

This is the core of our solution. Let’s call it LegacyIdPAuthenticationStateProvider.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json; // For PostAsJsonAsync, ReadFromJsonAsync
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.WebUtilities; // For QueryHelpers
using Microsoft.Extensions.Configuration; // For IConfiguration
using Microsoft.Extensions.Logging;

// Placeholder for IdP configuration class
public class IdPConfig
{
    public string ClientId { get; set; } = string.Empty;
    public string AuthorizationEndpoint { get; set; } = string.Empty;
    public string TokenEndpoint { get; set; } = string.Empty;
    public string RedirectUri { get; set; } = string.Empty;
    public string Scope { get; set; } = string.Empty;
    // Optional: Add if your IdP has a UserInfo endpoint
    public string? UserInfoEndpoint { get; set; } 
}

public class LegacyIdPAuthenticationStateProvider : AuthenticationStateProvider
{
    private readonly NavigationManager _navigationManager;
    private readonly HttpClient _httpClient;
    private readonly UserTokenStore _tokenStore;
    private readonly IdPConfig _idpConfig;
    private readonly ILogger<LegacyIdPAuthenticationStateProvider> _logger;

    // Temporary storage for PKCE verifier and state.
    // WARNING: Static fields are NOT suitable for concurrent users in Blazor Server.
    // Use ASP.NET Core Session state or IDistributedCache for production.
    // This is simplified for demonstration.
    private static string? _pendingCodeVerifier;
    private static string? _pendingState;

    public LegacyIdPAuthenticationStateProvider(
        // See: https://learn.microsoft.com/aspnet/core/blazor/fundamentals/routing#navigationmanager
        NavigationManager navigationManager, 
        HttpClient httpClient,
        UserTokenStore tokenStore,
        // See: https://learn.microsoft.com/aspnet/core/fundamentals/configuration
        IConfiguration configuration, 
        // See: https://learn.microsoft.com/aspnet/core/fundamentals/logging
        ILogger<LegacyIdPAuthenticationStateProvider> logger) 
    {
        _navigationManager = navigationManager;
        _httpClient = httpClient;
        _tokenStore = tokenStore;
        _logger = logger;
        _idpConfig = configuration.GetSection("LegacyIdP").Get<IdPConfig>()
            ?? throw new InvalidOperationException("IdPConfig not found.");
    }

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        var identity = new ClaimsIdentity();
        if (!string.IsNullOrEmpty(_tokenStore.AccessToken) &&
            _tokenStore.AccessTokenExpiresAt > DateTimeOffset.UtcNow)
        {
            try
            {
                // Option 1: Parse JWT locally if IdP issues JWTs
                // var claims = ParseJwtToken(_tokenStore.AccessToken);
                // identity = new ClaimsIdentity(claims, "OAuth2PKCE");

                // Option 2: Call UserInfo endpoint if available and preferred
                if (!string.IsNullOrEmpty(_idpConfig.UserInfoEndpoint))
                {
                     var userInfoClaims = await GetUserInfoAsync(_tokenStore.AccessToken);
                     identity = new ClaimsIdentity(userInfoClaims, "OAuth2PKCE_UserInfo");
                }
                else // Fallback or if token itself contains enough info (non-JWT)
                {
                    // This is a simplified version. For legacy IdPs, you might
                    // need custom logic to interpret the access token or map claims.
                    var claims = new List<Claim>
                    {
                        new Claim(ClaimTypes.Name, "UserFromLegacyToken"),
                        // Add other claims based on IdP or token inspection
                        // See: https://learn.microsoft.com/dotnet/api/system.security.claims.claimtypes
                    };
                    identity = new ClaimsIdentity(claims, "OAuth2PKCE_Basic");
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error processing token for claims.");
                // Token invalid or expired, clear it and treat as anonymous
                ClearTokens(); // Implement ClearTokens to nullify _tokenStore
            }
        }
        var user = new ClaimsPrincipal(identity);
        return new AuthenticationState(user);
    }

    // Example helper method (implement actual JWT parsing or UserInfo call)
    private async Task<IEnumerable<Claim>> GetUserInfoAsync(string accessToken)
    {
        var request = new HttpRequestMessage(HttpMethod.Get, _idpConfig.UserInfoEndpoint);
        request.Headers.Authorization = 
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
        
        var response = await _httpClient.SendAsync(request);
        if (!response.IsSuccessStatusCode)
        {
            _logger.LogError("Failed to get UserInfo: {StatusCode}", response.StatusCode);
            return Enumerable.Empty<Claim>();
        }
        
        // Parse the UserInfo response (typically JSON)
        // Adjust based on your IdP's UserInfo response structure
        var userInfo = await response.Content.ReadFromJsonAsync<Dictionary<string, JsonElement>>();
        var claims = new List<Claim>();
        if (userInfo != null)
        {
            // Example: Map 'sub' to NameIdentifier, 'name' to Name
            if (userInfo.TryGetValue("sub", out var sub)) 
                claims.Add(new Claim(ClaimTypes.NameIdentifier, sub.GetString()!));
            if (userInfo.TryGetValue("name", out var name)) 
                claims.Add(new Claim(ClaimTypes.Name, name.GetString()!));
            // Add other claims as needed from UserInfo
        }
        return claims;
    }


    public void InitiateLogin()
    {
        _pendingCodeVerifier = PkceUtil.GenerateCodeVerifier();
        var codeChallenge = PkceUtil.GenerateCodeChallenge(_pendingCodeVerifier);
        // Use state for CSRF protection: https://owasp.org/www-community/attacks/csrf
        _pendingState = Guid.NewGuid().ToString("N"); 

        var parameters = new Dictionary<string, string?>
        {
            { "client_id", _idpConfig.ClientId },
            { "response_type", "code" },
            { "redirect_uri", _idpConfig.RedirectUri },
            { "scope", _idpConfig.Scope },
            { "state", _pendingState },
            { "code_challenge", codeChallenge },
            { "code_challenge_method", "S256" }
        };

        var authorizationUrl = QueryHelpers.AddQueryString(
            _idpConfig.AuthorizationEndpoint, parameters);
        
        _logger.LogInformation(
            "Redirecting to IdP: {AuthorizationUrl}", authorizationUrl);
        // Force load for external navigation
        _navigationManager.NavigateTo(authorizationUrl, forceLoad: true); 
    }

    public async Task HandleAuthCallbackAsync(string code, string stateFromCallback)
    {
        _logger.LogInformation(
            "Handling auth callback. Code: {Code}, State: {State}", 
            code, stateFromCallback);

        if (string.IsNullOrEmpty(code))
        {
            _logger.LogError("Authorization code is missing in callback.");
            return; // Handle error appropriately
        }

        if (string.IsNullOrEmpty(stateFromCallback) || stateFromCallback != _pendingState)
        {
            _logger.LogError(
                "State mismatch. CSRF attack suspected or session issue.");
            _pendingState = null; 
            return; // Handle error
        }
        _pendingState = null; // Valid state, clear it

        if (string.IsNullOrEmpty(_pendingCodeVerifier))
        {
            _logger.LogError("Code verifier is missing. PKCE flow error.");
            return; // Handle error
        }
        var codeVerifierForTokenRequest = _pendingCodeVerifier;
        _pendingCodeVerifier = null; // Clear after use

        var tokenRequestBody = new Dictionary<string, string?>
        {
            { "grant_type", "authorization_code" },
            { "code", code },
            { "redirect_uri", _idpConfig.RedirectUri },
            { "client_id", _idpConfig.ClientId },
            { "code_verifier", codeVerifierForTokenRequest }
            // Some legacy IdPs might require client_secret even with PKCE
            // { "client_secret", "your_client_secret_if_required" }
        };

        try
        {
            // Use FormUrlEncodedContent for token requests
            // See: https://learn.microsoft.com/dotnet/api/system.net.http.formurlencodedcontent
            var tokenResponse = await _httpClient.PostAsync(
                _idpConfig.TokenEndpoint,
                new FormUrlEncodedContent(tokenRequestBody)
            );

            tokenResponse.EnsureSuccessStatusCode(); // Throws on non-2xx

            var responseContent = await tokenResponse.Content.ReadAsStringAsync();
            _logger.LogInformation("Token response: {ResponseContent}", responseContent);
            
            // Parse using System.Text.Json
            // See: https://learn.microsoft.com/dotnet/standard/serialization/system-text-json-overview
            var tokenData = JsonSerializer.Deserialize<JsonElement>(responseContent);
            
            // Use JsonElement.TryGetProperty for safer access
            // See: https://learn.microsoft.com/dotnet/api/system.text.json.jsonelement.trygetproperty
            _tokenStore.AccessToken = tokenData.TryGetProperty(
                "access_token", out var accessTokenElement
            )
                ? accessTokenElement.GetString()
                : null;
            
            _tokenStore.RefreshToken = tokenData.TryGetProperty(
                "refresh_token", out var refreshTokenElement
            )
                ? refreshTokenElement.GetString()
                : null;

            if (tokenData.TryGetProperty("expires_in", out var expiresInElement) &&
                expiresInElement.TryGetInt32(out int expiresIn))
            {
                _tokenStore.AccessTokenExpiresAt = 
                    DateTimeOffset.UtcNow.AddSeconds(expiresIn);
            }

            if (string.IsNullOrEmpty(_tokenStore.AccessToken))
            {
                _logger.LogError("Access token not found in response.");
                return; // Handle error
            }

            NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
        }
        catch (HttpRequestException ex)
        {
            _logger.LogError(ex, "HTTP request to token endpoint failed.");
            var errorContent = await ex.Response?.Content?.ReadAsStringAsync();
            _logger.LogError("IdP error response: {ErrorContent}", errorContent);
        }
        catch (JsonException ex)
        {
            _logger.LogError(ex, "Failed to deserialize token response.");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "An unexpected error occurred during token exchange.");
        }
    }
    
    private void ClearTokens()
    {
        _tokenStore.AccessToken = null;
        _tokenStore.RefreshToken = null;
        _tokenStore.AccessTokenExpiresAt = DateTimeOffset.MinValue;
    }

    public void Logout()
    {
        ClearTokens();
        _pendingCodeVerifier = null; 
        _pendingState = null;

        NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
        // Optionally, redirect to a specific logged-out page or the IdP's logout endpoint
        // if (_idpConfig.LogoutEndpoint != null) 
        // _navigationManager.NavigateTo(_idpConfig.LogoutEndpoint, forceLoad: true);
        _navigationManager.NavigateTo("/"); // Navigate to home
    }
}

Important Note on _pendingCodeVerifier and _pendingState: The use of static fields for _pendingCodeVerifier and _pendingState is a simplification for this example and is not safe for concurrent users in a production Blazor Server application. Each user’s PKCE state must be isolated. Consider using ASP.NET Core Session state or IDistributedCache with unique keys tied to the user’s interaction flow.

4. Registering Services in Program.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
using Microsoft.AspNetCore.Components.Authorization;
// ... other usings

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();

// Register the custom AuthenticationStateProvider and supporting services
builder.Services.AddScoped<UserTokenStore>(); // Scoped to the user's circuit
builder.Services.AddScoped<AuthenticationStateProvider, 
    LegacyIdPAuthenticationStateProvider>();

// Register HttpClient (ideally using IHttpClientFactory)
builder.Services.AddHttpClient(); // Makes HttpClient available for injection
// For named/typed clients: builder.Services.AddHttpClient<MyTypedClient>();

// Bind IdP configuration from appsettings.json
builder.Services.Configure<IdPConfig>(
    builder.Configuration.GetSection("LegacyIdP"));

// For robust PKCE state management, consider session state or distributed cache
// builder.Services.AddDistributedMemoryCache(); // Example for IDistributedCache
// builder.Services.AddSession(options => { /* ... */ });

var app = builder.Build();

// ... rest of Program.cs (middleware configuration)

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();

// If using session state for PKCE state:
// app.UseSession(); 

// Authentication and Authorization middleware
app.UseAuthentication(); // Not strictly needed for custom provider if not using Identity
app.UseAuthorization();  // Enables [Authorize] attribute functionality

app.MapBlazorHub();
app.MapFallbackToPage("/_Host");

// Define the callback endpoint using Minimal APIs
// See: https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis
app.MapGet("/auth/callback", async (
    string? code, 
    string? state, 
    string? error, 
    string? error_description, 
    LegacyIdPAuthenticationStateProvider authProvider,
    NavigationManager navManager,
    ILoggerFactory loggerFactory) =>
{
    var logger = loggerFactory.CreateLogger("AuthCallbackEndpoint");
    if (!string.IsNullOrEmpty(error))
    {
        logger.LogError(
            "OAuth error callback: {Error}, Description: {ErrorDescription}",
            error, error_description);
        navManager.NavigateTo(
            $"/auth-error?message={Uri.EscapeDataString(error_description ?? error)}", 
            forceLoad: true);
        return;
    }

    if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)) {
        logger.LogError("Callback missing code or state parameter.");
        navManager.NavigateTo(
            "/auth-error?message=InvalidCallbackParameters", 
            forceLoad: true);
        return;
    }
    
    await authProvider.HandleAuthCallbackAsync(code, state);
    navManager.NavigateTo("/", forceLoad: true); // Redirect to home after login
});

// An example error page for auth failures
app.MapGet("/auth-error", (string? message) => 
    Results.Text($"Authentication Error: {message ?? "Unknown error."}", 
    "text/plain"));

app.Run();

The /auth/callback endpoint is crucial. It receives the authorization code from the IdP and triggers the token exchange process.

5. UI Integration (Login/Logout Controls)

In your MainLayout.razor or a dedicated login component. Ensure App.razor wraps the Router with <CascadingAuthenticationState>.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@using Microsoft.AspNetCore.Components.Authorization
@inject AuthenticationStateProvider AuthProvider
@inject NavigationManager NavManager

<div class="top-row px-4 auth-controls">
    <AuthorizeView> @* See: https://learn.microsoft.com/aspnet/core/blazor/security/ \ 
    ?view=aspnetcore-8.0#authorizeview-component *@
        <Authorized>
            <span>Hello, @context.User.Identity?.Name!</span>
            <button class="ml-md-auto btn btn-link" @onclick="HandleLogout">
                Log out
            </button>
        </Authorized>
        <NotAuthorized>
            <button class="ml-md-auto btn btn-link" @onclick="HandleLogin">
                Log in
            </button>
        </NotAuthorized>
    </AuthorizeView>
</div>

@code {
    private void HandleLogin()
    {
        if (AuthProvider is LegacyIdPAuthenticationStateProvider customProvider)
        {
            customProvider.InitiateLogin();
        }
        else
        {
            NavManager.NavigateTo(
                "/auth-error?message=InvalidAuthProviderType", true);
        }
    }

    private void HandleLogout()
    {
        if (AuthProvider is LegacyIdPAuthenticationStateProvider customProvider)
        {
            customProvider.Logout();
            // Logout method in provider should handle navigation if needed
        }
    }
}

And a simple page to display user claims, protected by the [Authorize] attribute:

Pages/UserProfile.razor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@page "/user-profile"
@using System.Security.Claims
@attribute [Authorize] 

<h3>User Profile</h3>

<AuthorizeView>
    <Authorized>
        <p>Hello, @context.User.Identity?.Name!</p>
        @if (context.User.Claims.Any())
        {
            <h4>Your Claims:</h4>
            <ul>
                @foreach (var claim in context.User.Claims)
                {
                    <li><strong>@claim.Type</strong>: @claim.Value</li>
                }
            </ul>
        }
        else
        {
            <p>No claims found for this user.</p>
        }
    </Authorized>
    <NotAuthorized>
        <p>You are not authorized to see this page. Please log in.</p>
    </NotAuthorized>
</AuthorizeView>

Addressing Legacy IdP Quirks

The primary benefit of a custom AuthenticationStateProvider is its adaptability:

  • Non-Standard Endpoints/Parameters: You control the exact URLs and parameters sent to the IdP in InitiateLogin and HandleAuthCallbackAsync.
  • Token Response Variations: The HandleAuthCallbackAsync method parses the JSON response. Adjust JsonSerializer.Deserialize or TryGetProperty calls for IdP-specific token property names.
  • Claim Mapping: Manually create Claim objects from the token or UserInfo response, mapping proprietary claim names (e.g., legacy_user_id) to standard ClaimTypes.
  • PKCE Flexibility: If the IdP expects code_challenge_method=PLAIN (not recommended) or has unusual code_verifier requirements, PkceUtil and request parameters can be adapted.
  • Client Secrets with PKCE: Your tokenRequestBody can include client_secret if the IdP mandates it for confidential clients (like Blazor Server) even when using PKCE. Store secrets securely.

Debugging Tips

  • Logging: Add extensive logging in your LegacyIdPAuthenticationStateProvider and the /auth/callback endpoint.
  • Browser Developer Tools: Use the Network tab to trace redirects, inspect parameters, and view IdP responses.
  • IdP Logs: If accessible, IdP logs are invaluable for diagnosing server-side IdP errors.
  • Test Incrementally: Verify each step: authorization redirect, callback, code exchange, token parsing, and finally claim population.

Conclusion

Implementing a custom AuthenticationStateProvider in Blazor Server, while more involved than using built-in middleware, offers unparalleled flexibility for integrating with OAuth 2.0 / PKCE flows, especially when dealing with legacy Identity Providers. By taking control of each step – from PKCE generation and IdP redirection to token exchange and claims processing – you can effectively navigate non-standard behaviors and ensure secure authentication for your application.

Remember to handle tokens securely on the server, implement robust PKCE state management (avoiding static fields for per-user state in production), and adapt the claim mapping logic to match your specific legacy IdP’s conventions. This custom approach empowers you to bring modern security practices like PKCE to systems that might not fully support the latest standards out-of-the-box.