# SSR Authentication Migration — Implementation Notes ## Overview Migration from WASM client-side authentication to SSR (Server-Side Rendering) authentication for `EnvelopeReceiverPage.razor` to fix authentication issues in Blazor InteractiveServer mode. --- ## Problem Statement ### Issue `EnvelopeReceiverPage.razor` uses `@rendermode InteractiveServer` but was calling **WASM client service** `AuthService.CheckEnvelopeAccessAsync()`: ```razor @inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService var hasAccess = await AuthService.CheckEnvelopeAccessAsync(EnvelopeKey); ``` **Why This Failed:** - `AuthService` is a **WASM client service** that uses `IHttpClientFactory` - In SSR context, `HttpContext` is required to configure the base address - `CheckEnvelopeAccessAsync()` makes an HTTP request to `/api/auth/check/envelope/{key}` - This request **goes to itself** (server calling its own endpoint), causing issues - Returns `false` even when user is authenticated --- ## Solution Architecture ### Created New SSR Authentication Service **Files Created:** 1. `EnvelopeGenerator.Server/Services/IEnvelopeAuthService.cs` (Interface) 2. `EnvelopeGenerator.Server/Services/EnvelopeAuthService.cs` (Implementation) **Purpose:** Direct `HttpContext.User` validation without HTTP requests --- ## Implementation Details ### 1. IEnvelopeAuthService Interface **Location:** `EnvelopeGenerator.Server/Services/IEnvelopeAuthService.cs` ```csharp namespace EnvelopeGenerator.Server.Services; public interface IEnvelopeAuthService { /// /// Checks if the current user is authenticated for the given envelope key. /// Validates both that the user is authenticated AND that the envelope key matches their claims. /// bool IsAuthenticated(string envelopeKey); /// /// Gets the authenticated envelope key from the current user's claims (NameIdentifier or "sub" claim). /// string? GetAuthenticatedEnvelopeKey(); /// /// Gets the current HttpContext user principal. /// ClaimsPrincipal? GetCurrentUser(); } ``` **Key Methods:** - `IsAuthenticated(string envelopeKey)`: Validates user auth + envelope key match - `GetAuthenticatedEnvelopeKey()`: Extracts envelope key from claims - `GetCurrentUser()`: Returns `ClaimsPrincipal` for advanced scenarios --- ### 2. EnvelopeAuthService Implementation **Location:** `EnvelopeGenerator.Server/Services/EnvelopeAuthService.cs` **Dependencies:** - `IHttpContextAccessor`: Access current HTTP context - `ILogger`: Structured logging **Logic:** ```csharp public bool IsAuthenticated(string envelopeKey) { // 1. Validate envelope key parameter if (string.IsNullOrWhiteSpace(envelopeKey)) return false; // 2. Get HttpContext var context = _httpContextAccessor.HttpContext; // 3. Check if user is authenticated if (context?.User?.Identity?.IsAuthenticated != true) return false; // 4. Extract envelope key from claims var sub = GetEnvelopeKeyFromClaims(context.User); // 5. Verify match return sub == envelopeKey; } private string? GetEnvelopeKeyFromClaims(ClaimsPrincipal user) { // Try standard claim first var sub = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; // Fallback to JWT "sub" claim if (string.IsNullOrWhiteSpace(sub)) sub = user.FindFirst("sub")?.Value; return sub; } ``` **Claim Priority:** 1. `ClaimTypes.NameIdentifier` (standard .NET claim) 2. `"sub"` (JWT standard claim) --- ### 3. Service Registration **Location:** `EnvelopeGenerator.Server/Program.cs` **Added:** ```csharp // SSR Authentication Service (for Envelope Receiver pages) builder.Services.AddScoped(); ``` **Lifetime:** `Scoped` (per-request, matches `IHttpContextAccessor`) --- ### 4. EnvelopeReceiverPage.razor Changes **Changes Made (REVERTED - To Be Re-Applied):** #### 4.1 Using Statements ```razor @using EnvelopeGenerator.Server.Services ``` #### 4.2 Dependency Injection **Old:** ```razor @inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService ``` **New:** ```razor @inject IEnvelopeAuthService EnvelopeAuth @inject IHttpClientFactory HttpClientFactory ``` #### 4.3 Authentication Check in `OnInitializedAsync()` **Old:** ```csharp var hasAccess = await AuthService.CheckEnvelopeAccessAsync(EnvelopeKey); if (!hasAccess) { Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}"); return; } ``` **New:** ```csharp // ? SSR Authentication check via service if (!EnvelopeAuth.IsAuthenticated(EnvelopeKey)) { Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}"); return; } ``` **Benefits:** - ? Synchronous (no HTTP overhead) - ? Direct `HttpContext.User` access - ? No self-referencing HTTP calls - ? Works in SSR context #### 4.4 Logout Method **Old:** ```csharp await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey); ``` **New:** ```csharp try { // ? SSR: Direct HTTP call instead of WASM client service using var http = HttpClientFactory.CreateClient("EnvelopeGenerator.Server"); await http.PostAsync($"/api/auth/logout/envelope/{Uri.EscapeDataString(EnvelopeKey)}", null); } catch (Exception ex) { logger.LogError(ex, "Logout failed for envelope {EnvelopeKey}", EnvelopeKey); } Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true); ``` **Why Changed:** - WASM `AuthService.LogoutEnvelopeReceiverAsync()` doesn't work in SSR - Use named HttpClient `"EnvelopeGenerator.Server"` (configured in `Program.cs`) - Graceful error handling (logout errors shouldn't block redirect) --- ## Remaining Tasks ### ? Completed 1. ? Created `IEnvelopeAuthService` interface 2. ? Implemented `EnvelopeAuthService` with `HttpContext` access 3. ? Registered service in `Program.cs` 4. ?? **REVERTED** `EnvelopeReceiverPage.razor` changes (merge conflict) ### ? TODO (Next Agent) #### 1. Re-apply EnvelopeReceiverPage.razor Changes **File:** `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage.razor` **Steps:** 1. Add using statement: ```razor @using EnvelopeGenerator.Server.Services ``` 2. Replace injection: ```razor @inject IEnvelopeAuthService EnvelopeAuth @inject IHttpClientFactory HttpClientFactory ``` Remove: ```razor @inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService ``` 3. Update `OnInitializedAsync()` authentication check: ```csharp // Replace this: var hasAccess = await AuthService.CheckEnvelopeAccessAsync(EnvelopeKey); if (!hasAccess) { Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}"); return; } // With this: if (!EnvelopeAuth.IsAuthenticated(EnvelopeKey)) { Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}"); return; } ``` 4. Update `LogoutAsync()` method: ```csharp async Task LogoutAsync() { if (string.IsNullOrWhiteSpace(EnvelopeKey) || _isLoggingOut) return; _isLoggingOut = true; await InvokeAsync(StateHasChanged); try { // ? SSR: Direct HTTP call instead of WASM client service using var http = HttpClientFactory.CreateClient("EnvelopeGenerator.Server"); await http.PostAsync($"/api/auth/logout/envelope/{Uri.EscapeDataString(EnvelopeKey)}", null); } catch (Exception ex) { logger.LogError(ex, "Logout failed for envelope {EnvelopeKey}", EnvelopeKey); } Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true); } ``` #### 2. Test Authentication Flow **Scenarios:** - ? Valid cookie ? Page loads - ? Invalid cookie ? Redirect to login - ? No cookie ? Redirect to login - ? Envelope key mismatch ? Redirect to login - ? Logout ? Cookie cleared, redirect to login #### 3. Remove WASM Client Services from SSR Pages **Optional Cleanup:** - Review other SSR pages (`EnvelopeReceiverPage_DxPdfViewer.razor`, etc.) - Replace WASM client services with SSR equivalents where applicable - Document which services are WASM-only vs SSR-compatible --- ## Authentication Flow Comparison ### ? Old Flow (WASM Client Service in SSR) ``` EnvelopeReceiverPage (@rendermode InteractiveServer) ? AuthService.CheckEnvelopeAccessAsync() (WASM client) ? IHttpClientFactory.CreateClient("EnvelopeGenerator.Server") ? GET /api/auth/check/envelope/{key} ? [SELF-REFERENCING REQUEST - FAILS] ? Returns false even when authenticated ``` ### ? New Flow (SSR Service) ``` EnvelopeReceiverPage (@rendermode InteractiveServer) ? IEnvelopeAuthService.IsAuthenticated(envelopeKey) ? IHttpContextAccessor.HttpContext.User (Direct access) ? ClaimsPrincipal.FindFirst("sub" or NameIdentifier) ? Compare with envelopeKey ? Return true/false (synchronous, no HTTP) ``` --- ## Technical Decisions ### Why Not Use `[Authorize]` Attribute? - Blazor SSR components **don't support** `[Authorize]` at component level - Would require `` component (less clean) - Custom service provides more control + logging ### Why Scoped Lifetime? - `IHttpContextAccessor` is scoped (per-request) - `EnvelopeAuthService` depends on `IHttpContextAccessor` - Scoped ensures same `HttpContext` throughout request ### Why Two Claims (`NameIdentifier` + `"sub"`)? - **`NameIdentifier`**: Standard .NET claim type - **`"sub"`**: JWT standard claim - Fallback ensures compatibility with different token formats --- ## Logging & Debugging ### Log Levels - **Debug:** Successful authentication - **Warning:** Null envelope key, key mismatch - **Error:** (Reserved for future exceptions) ### Sample Logs ``` [Debug] User authenticated for envelope 517bb9c5-6082-4e61-aaa5-9846386e67ee [Warning] Envelope key mismatch: Expected abc123, Got 517bb9c5-6082-4e61-aaa5-9846386e67ee [Warning] IsAuthenticated called with null or empty envelope key ``` --- ## Testing Checklist ### Unit Tests (TODO) ```csharp // EnvelopeGenerator.Tests/Services/EnvelopeAuthServiceTests.cs [Fact] public void IsAuthenticated_ValidUser_ReturnsTrue() { ... } [Fact] public void IsAuthenticated_InvalidKey_ReturnsFalse() { ... } [Fact] public void IsAuthenticated_UnauthenticatedUser_ReturnsFalse() { ... } [Fact] public void GetAuthenticatedEnvelopeKey_ValidUser_ReturnsKey() { ... } ``` ### Integration Tests (Manual) 1. ? Login with valid access code ? Cookie set 2. ? Navigate to `/envelope/{key}` ? Page loads 3. ? Logout ? Cookie cleared, redirect 4. ? Try accessing `/envelope/{key}` without cookie ? Redirect to login 5. ? Try accessing `/envelope/{wrongKey}` with valid cookie ? Redirect to login --- ## Migration Checklist - [x] Create `IEnvelopeAuthService` interface - [x] Implement `EnvelopeAuthService` - [x] Register service in `Program.cs` - [ ] **Re-apply** `EnvelopeReceiverPage.razor` changes (after merge) - [ ] Test authentication flow - [ ] Add unit tests - [ ] Update other SSR pages (if needed) - [ ] Document in `COPILOT_CONTEXT.md` --- ## Documentation Updates Needed ### COPILOT_CONTEXT.md **Add Section:** ```markdown ## SSR Authentication Service **Purpose:** Server-side authentication for Blazor InteractiveServer pages. **Location:** `EnvelopeGenerator.Server/Services/` **Service:** `IEnvelopeAuthService` / `EnvelopeAuthService` **Usage:** ```razor @inject IEnvelopeAuthService EnvelopeAuth protected override async Task OnInitializedAsync() { if (!EnvelopeAuth.IsAuthenticated(EnvelopeKey)) { Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}"); return; } } ``` **Why Not Use WASM Client Services in SSR?** - WASM client services use `IHttpClientFactory` with base address configuration - SSR context requires `HttpContext` to configure base address - Calling API endpoints from server-side component creates self-referencing requests - Use `IEnvelopeAuthService` for direct `HttpContext.User` access instead **Authentication Flow:** 1. JWT token stored in per-envelope cookie (`AuthTokenSignFLOWReceiver.{envelopeKey}`) 2. JWT middleware validates token, sets `HttpContext.User` 3. `EnvelopeAuthService` checks `ClaimsPrincipal.FindFirst("sub")` or `NameIdentifier` 4. Compares claim value with route parameter `{EnvelopeKey}` **Claim Priority:** 1. `ClaimTypes.NameIdentifier` (standard .NET) 2. `"sub"` (JWT standard) **Service Lifetime:** Scoped (per-request) ``` --- ## Common Mistakes to Avoid ### ? Don't Do This ```csharp // SSR page using WASM client service @inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService var hasAccess = await AuthService.CheckEnvelopeAccessAsync(EnvelopeKey); ``` **Why Wrong:** - Creates self-referencing HTTP request - WASM client service doesn't work in SSR context - Always returns `false` even when authenticated ### ? Do This Instead ```csharp // SSR page using SSR authentication service @inject IEnvelopeAuthService EnvelopeAuth if (!EnvelopeAuth.IsAuthenticated(EnvelopeKey)) { Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}"); return; } ``` **Why Correct:** - Direct `HttpContext.User` access - Synchronous (no HTTP overhead) - Works in SSR context --- ## References ### Related Files - `EnvelopeGenerator.Server/Program.cs` (Service registration, JWT middleware) - `EnvelopeGenerator.Server.Client/Services/AuthService.cs` (WASM client version) - `EnvelopeGenerator.Server.Client/Pages/LoginReceiverPage.razor` (WASM login page) - `EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage.razor` (SSR viewer page) ### JWT Configuration **File:** `EnvelopeGenerator.Server/Program.cs` ```csharp .AddJwtBearer(AuthScheme.Receiver, opt => { opt.Events = new JwtBearerEvents { OnMessageReceived = context => { var envelopeKey = context.Request.Path.Value?.Split('/').LastOrDefault(); if (envelopeKey is not null) { var cookieName = CookieNames.GetEnvelopeReceiverCookieName(authTokenKeys.Cookie, envelopeKey); if (context.Request.Cookies.TryGetValue(cookieName, out var cookieToken)) context.Token = cookieToken; } return Task.CompletedTask; }, OnTokenValidated = context => { var envelopeKey = context.Request.Path.Value?.Split('/').LastOrDefault(); var sub = context.Principal?.FindFirst("sub")?.Value; if (envelopeKey is null || sub != envelopeKey) context.Fail("Envelope key mismatch"); return Task.CompletedTask; } }; }); ``` --- ## Summary **What Was Done:** 1. Created SSR authentication service (`IEnvelopeAuthService` / `EnvelopeAuthService`) 2. Registered service in DI container 3. Updated `EnvelopeReceiverPage.razor` (REVERTED due to merge) **What's Left:** 1. **Re-apply** `EnvelopeReceiverPage.razor` changes after merge 2. Test authentication flow 3. Add unit tests 4. Update documentation **Key Insight:** - **WASM client services ? SSR server services** - Use `IHttpContextAccessor` for direct `HttpContext.User` access in SSR - Avoid HTTP requests from server-side components to own endpoints --- **Last Updated:** 2025-01-27 **Status:** ?? Partial (Service created, page changes reverted for merge) **Next Agent:** Re-apply `EnvelopeReceiverPage.razor` changes + testing