Replaced the WASM client-side authentication service with a new SSR (Server-Side Rendering) authentication service to resolve issues in SSR mode caused by self-referencing HTTP requests and the lack of `HttpContext`. Added `IEnvelopeAuthService` interface and its implementation `EnvelopeAuthService`, which directly accesses `HttpContext.User` to validate user authentication and claims. Registered the service in the DI container with a scoped lifetime. Updated `EnvelopeReceiverPage.razor` to use the new SSR service for authentication checks and logout logic. Changes to the page were reverted due to a merge conflict, with a detailed plan provided for re-application. Improved logging for debugging authentication issues and outlined a migration checklist, including testing, unit tests, and documentation updates. These changes improve performance, ensure SSR compatibility, and eliminate unnecessary HTTP requests.
554 lines
15 KiB
Markdown
554 lines
15 KiB
Markdown
# 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
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
bool IsAuthenticated(string envelopeKey);
|
|
|
|
/// <summary>
|
|
/// Gets the authenticated envelope key from the current user's claims (NameIdentifier or "sub" claim).
|
|
/// </summary>
|
|
string? GetAuthenticatedEnvelopeKey();
|
|
|
|
/// <summary>
|
|
/// Gets the current HttpContext user principal.
|
|
/// </summary>
|
|
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<EnvelopeAuthService>`: 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<EnvelopeGenerator.Server.Services.IEnvelopeAuthService,
|
|
EnvelopeGenerator.Server.Services.EnvelopeAuthService>();
|
|
```
|
|
|
|
**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 `<AuthorizeView>` 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
|