Skip to content

Commit 8d650b6

Browse files
davidortinauCopilot
andcommitted
feat: add OIDC authentication to WebApp (#44)
Microsoft.Identity.Web OIDC for Entra ID sign-in. Conditional auth via UseEntraId flag. Token acquisition for downstream API calls. Redis-backed token cache. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6919e9d commit 8d650b6

File tree

9 files changed

+193
-4
lines changed

9 files changed

+193
-4
lines changed

.squad/agents/kaylee/history.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99

1010
<!-- Append new learnings below. Each entry is something lasting about the project. -->
1111

12+
- `Auth:UseEntraId` config flag controls auth mode in both API and WebApp — false = DevAuthHandler, true = Entra ID OIDC
13+
- Microsoft.Identity.Web OIDC uses `AddMicrosoftIdentityWebApp()` + `EnableTokenAcquisitionToCallDownstreamApi()` chain
14+
- Redis-backed distributed token cache via `Aspire.StackExchange.Redis.DistributedCaching` (match AppHost Aspire version for preview packages)
15+
- `ConfigureHttpClientDefaults` adds DelegatingHandler to ALL HttpClient instances from the factory
16+
- Microsoft.Identity.Web.UI requires `AddControllersWithViews()` + `MapControllers()` for sign-in/sign-out endpoints
17+
- `appsettings.json` is gitignored — config changes there are local-only, use `appsettings.Development.json` for tracked dev config
18+
- Client secrets go in user-secrets, never in tracked config files
19+
1220
- Blazor pages in `src/SentenceStudio.UI/Pages/` — follow `activity-page-wrapper` layout pattern
1321
- MauiReactor conventions: `VStart()` not `Top()`, `VEnd()` not `Bottom()`, `HStart()`/`HEnd()` not `Start()`/`End()`
1422
- NEVER use emojis in UI — use Bootstrap icons (bi-*) or text. Non-negotiable.
@@ -39,3 +47,17 @@
3947

4048
**Critical Path:** CoreSync SQLite→PostgreSQL migration (#55, XL).
4149

50+
### 2026-03-14 — WebApp OIDC Authentication (#44)
51+
52+
**Status:** Complete
53+
**Branch:** `feature/44-webapp-oidc`
54+
55+
Added OIDC authentication to the Blazor WebApp:
56+
- NuGet: Microsoft.Identity.Web, .UI, .DownstreamApi, Aspire Redis distributed cache
57+
- Conditional auth via `Auth:UseEntraId` flag (false = DevAuthHandler, true = Entra ID)
58+
- `AuthenticatedApiDelegatingHandler` attaches Bearer tokens to all outgoing API calls
59+
- Redis-backed distributed token cache (Aspire integration, matches AppHost Aspire version)
60+
- `LoginDisplay.razor` with Bootstrap icons (bi-person, bi-box-arrow-right)
61+
- `CascadingAuthenticationState` in App.razor
62+
- Build verified: zero new errors (pre-existing DuplicateGroup issue in SentenceStudio.UI is unrelated)
63+
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Decision: WebApp OIDC Authentication (#44)
2+
3+
**Date:** 2026-03-14
4+
**Author:** Kaylee (Full-stack Dev)
5+
**Status:** IMPLEMENTED
6+
**Branch:** `feature/44-webapp-oidc`
7+
8+
## Summary
9+
10+
Added Microsoft.Identity.Web OIDC authentication to the Blazor WebApp with the same conditional `Auth:UseEntraId` pattern used in the API (Wash's #43 work).
11+
12+
## What Changed
13+
14+
### NuGet Packages Added
15+
- `Microsoft.Identity.Web` — OIDC/OpenID Connect integration
16+
- `Microsoft.Identity.Web.UI` — Sign-in/sign-out controller endpoints
17+
- `Microsoft.Identity.Web.DownstreamApi` — Token acquisition for API calls
18+
- `Aspire.StackExchange.Redis.DistributedCaching` (v13.3.0-preview.1.26156.1) — Redis-backed distributed token cache
19+
20+
### Auth Flow (Conditional)
21+
22+
When `Auth:UseEntraId` is **true**:
23+
- `AddMicrosoftIdentityWebApp()` configures OIDC with Entra ID
24+
- `EnableTokenAcquisitionToCallDownstreamApi()` acquires tokens for API
25+
- `AddDistributedTokenCaches()` + `AddRedisDistributedCache("cache")` for Redis-backed token cache
26+
- `AuthenticatedApiDelegatingHandler` attaches Bearer tokens to all outgoing HttpClient calls
27+
- `AddMicrosoftIdentityUI()` provides `/MicrosoftIdentity/Account/SignIn` and `SignOut` endpoints
28+
29+
When `Auth:UseEntraId` is **false** (default):
30+
- Existing `DevAuthHandler` provides auto-authenticated dev user (no config needed)
31+
32+
### New Files
33+
- `Auth/AuthenticatedApiDelegatingHandler.cs` — DelegatingHandler using ITokenAcquisition
34+
- `Components/Layout/LoginDisplay.razor` — Sign-in/sign-out UI with Bootstrap icons (bi-person, bi-box-arrow-right)
35+
36+
### Modified Files
37+
- `Program.cs` — Conditional auth registration, HttpClient handler wiring, MapControllers
38+
- `SentenceStudio.WebApp.csproj` — NuGet packages
39+
- `Components/App.razor``<CascadingAuthenticationState>` wrapper
40+
- `Components/Layout/MainLayout.razor` — LoginDisplay in top-row
41+
- `Components/_Imports.razor``Microsoft.AspNetCore.Components.Authorization` using
42+
- `appsettings.json` (gitignored) — AzureAd, Auth, DownstreamApi sections
43+
44+
### Configuration Required for Production
45+
1. Set `Auth:UseEntraId` to `true`
46+
2. Store client secret in user-secrets: `dotnet user-secrets set "AzureAd:ClientSecret" "<value>"`
47+
3. Redis must be running (Aspire AppHost already configures this)
48+
49+
## Design Rationale
50+
51+
- **Conditional pattern:** Matches API's DevAuthHandler approach — zero friction for local dev
52+
- **Redis token cache:** WebApp is server-rendered, needs shared token cache; Redis already in AppHost
53+
- **DelegatingHandler on all HttpClients:** `ConfigureHttpClientDefaults` ensures all API clients get auth tokens automatically
54+
- **No emojis:** Bootstrap icons only per team standards
55+
56+
## Dependencies
57+
- Requires Entra ID app registration (#42) for production use
58+
- Works alongside API auth (#43) — same tenant/scopes
59+
- Redis resource in AppHost (already configured)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using Microsoft.Identity.Web;
2+
3+
namespace SentenceStudio.WebApp.Auth;
4+
5+
/// <summary>
6+
/// Attaches a Bearer token to outgoing API calls when Entra ID auth is active.
7+
/// </summary>
8+
public sealed class AuthenticatedApiDelegatingHandler : DelegatingHandler
9+
{
10+
private readonly ITokenAcquisition _tokenAcquisition;
11+
private readonly IConfiguration _configuration;
12+
private readonly ILogger<AuthenticatedApiDelegatingHandler> _logger;
13+
14+
public AuthenticatedApiDelegatingHandler(
15+
ITokenAcquisition tokenAcquisition,
16+
IConfiguration configuration,
17+
ILogger<AuthenticatedApiDelegatingHandler> logger)
18+
{
19+
_tokenAcquisition = tokenAcquisition;
20+
_configuration = configuration;
21+
_logger = logger;
22+
}
23+
24+
protected override async Task<HttpResponseMessage> SendAsync(
25+
HttpRequestMessage request,
26+
CancellationToken cancellationToken)
27+
{
28+
try
29+
{
30+
var scopes = _configuration.GetSection("DownstreamApi:Scopes").Get<string[]>()
31+
?? ["api://8c051bcf-bd3a-4051-9cd3-0556ba5df2d8/.default"];
32+
33+
var token = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes);
34+
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
35+
}
36+
catch (Exception ex)
37+
{
38+
_logger.LogWarning(ex, "Failed to acquire token for downstream API call");
39+
}
40+
41+
return await base.SendAsync(request, cancellationToken);
42+
}
43+
}

src/SentenceStudio.WebApp/Components/App.razor

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
</head>
1919

2020
<body>
21-
<SentenceStudio.WebUI.Routes @rendermode="InteractiveServer" />
21+
<CascadingAuthenticationState>
22+
<SentenceStudio.WebUI.Routes @rendermode="InteractiveServer" />
23+
</CascadingAuthenticationState>
2224
<ReconnectModal />
2325
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
2426
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
@using Microsoft.AspNetCore.Components.Authorization
2+
3+
<AuthorizeView>
4+
<Authorized>
5+
<div class="d-flex align-items-center gap-2">
6+
<span class="text-light">
7+
<i class="bi bi-person-circle me-1"></i>@context.User.Identity?.Name
8+
</span>
9+
<a href="MicrosoftIdentity/Account/SignOut" class="btn btn-outline-light btn-sm">
10+
<i class="bi bi-box-arrow-right me-1"></i>Sign out
11+
</a>
12+
</div>
13+
</Authorized>
14+
<NotAuthorized>
15+
<a href="MicrosoftIdentity/Account/SignIn" class="btn btn-outline-light btn-sm">
16+
<i class="bi bi-person me-1"></i>Sign in
17+
</a>
18+
</NotAuthorized>
19+
</AuthorizeView>

src/SentenceStudio.WebApp/Components/Layout/MainLayout.razor

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
</div>
77

88
<main>
9-
<div class="top-row px-4">
9+
<div class="top-row px-4 d-flex justify-content-end align-items-center gap-3">
10+
<LoginDisplay />
1011
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
1112
</div>
1213

src/SentenceStudio.WebApp/Components/_Imports.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@
1111
@using SentenceStudio.WebApp.Components.Layout
1212
@using SentenceStudio.WebUI
1313
@using SentenceStudio.WebUI.Services
14+
@using Microsoft.AspNetCore.Components.Authorization
1415
@using SentenceStudio.Abstractions

src/SentenceStudio.WebApp/Program.cs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
using ElevenLabs;
22
using Microsoft.AspNetCore.Authentication;
3+
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
34
using Microsoft.Extensions.AI;
5+
using Microsoft.Identity.Web;
6+
using Microsoft.Identity.Web.UI;
47
using OpenAI;
58
using Plugin.Maui.Audio;
69
using SentenceStudio.WebApp.Platform;
@@ -46,8 +49,34 @@
4649

4750
builder.Services.AddRazorComponents()
4851
.AddInteractiveServerComponents();
49-
builder.Services.AddAuthentication(DevAuthHandler.SchemeName)
50-
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(DevAuthHandler.SchemeName, _ => { });
52+
53+
var useEntraId = builder.Configuration.GetValue<bool>("Auth:UseEntraId");
54+
55+
if (useEntraId)
56+
{
57+
builder.Services.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"))
58+
.EnableTokenAcquisitionToCallDownstreamApi(
59+
builder.Configuration.GetSection("DownstreamApi:Scopes").Get<string[]>()
60+
?? ["api://8c051bcf-bd3a-4051-9cd3-0556ba5df2d8/.default"])
61+
.AddDistributedTokenCaches();
62+
63+
builder.AddRedisDistributedCache("cache");
64+
65+
builder.Services.AddTransient<AuthenticatedApiDelegatingHandler>();
66+
builder.Services.ConfigureHttpClientDefaults(http =>
67+
{
68+
http.AddHttpMessageHandler<AuthenticatedApiDelegatingHandler>();
69+
});
70+
71+
builder.Services.AddControllersWithViews()
72+
.AddMicrosoftIdentityUI();
73+
}
74+
else
75+
{
76+
builder.Services.AddAuthentication(DevAuthHandler.SchemeName)
77+
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(DevAuthHandler.SchemeName, _ => { });
78+
}
79+
5180
builder.Services.AddAuthorization();
5281

5382
builder.Services.AddSingleton<IPreferencesService>(_ => new WebPreferencesService(preferencesPath));
@@ -110,6 +139,12 @@
110139
app.UseAntiforgery();
111140

112141
app.MapStaticAssets();
142+
143+
if (useEntraId)
144+
{
145+
app.MapControllers();
146+
}
147+
113148
app.MapRazorComponents<App>()
114149
.AddAdditionalAssemblies(typeof(SentenceStudio.WebUI.Routes).Assembly)
115150
.AddInteractiveServerRenderMode();

src/SentenceStudio.WebApp/SentenceStudio.WebApp.csproj

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
<Project Sdk="Microsoft.NET.Sdk.Web">
22

3+
<ItemGroup>
4+
<PackageReference Include="Aspire.StackExchange.Redis.DistributedCaching" Version="13.3.0-preview.1.26156.1" />
5+
<PackageReference Include="Microsoft.Identity.Web" Version="*" />
6+
<PackageReference Include="Microsoft.Identity.Web.DownstreamApi" Version="*" />
7+
<PackageReference Include="Microsoft.Identity.Web.UI" Version="*" />
8+
</ItemGroup>
9+
310
<ItemGroup>
411
<ProjectReference Include="..\SentenceStudio.AppLib\SentenceStudio.AppLib.csproj" />
512
<ProjectReference Include="..\SentenceStudio.Domain\SentenceStudio.Domain.csproj" />

0 commit comments

Comments
 (0)