Skip to content

Commit 9e0bc4c

Browse files
authored
Merge pull request #68 from davidortinau/feature/43-api-jwt-bearer
feat: add JWT Bearer authentication to API (#43)
2 parents 95b813c + ab3294b commit 9e0bc4c

File tree

9 files changed

+132
-39
lines changed

9 files changed

+132
-39
lines changed

.squad/agents/jayne/history.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@
1818
- "It compiles" is NOT sufficient — must verify in running app
1919
- Must call `CacheService.InvalidateVocabSummary()` after recording attempts or dashboard is stale
2020
- Playwright must use `pressSequentially` not `fill()` for Blazor server-side binding
21-
- "It compiles" is NOT sufficient — must verify in running app
22-
- Must call `CacheService.InvalidateVocabSummary()` after recording attempts or dashboard is stale
23-
- Playwright must use `pressSequentially` not `fill()` for Blazor server-side binding
2421
- Test users: David (Korean, f452438c-...), Jose (Spanish, 8d5f7b4a-...), Gunther (German, c3bb57f7-...)
2522

2623
## Work Sessions

.squad/agents/wash/history.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@
1717
- DI registration in `SentenceStudioAppBuilder.cs` (AppLib) and `Program.cs` (WebApp)
1818
- Aspire env var config: `builder.Configuration["AI:OpenAI:ApiKey"]` not `["AI__OpenAI__ApiKey"]`
1919
- Server DB at: `/Users/davidortinau/Library/Application Support/sentencestudio/server/sentencestudio.db`
20-
- Server DB at: `/Users/davidortinau/Library/Application Support/sentencestudio/server/sentencestudio.db`
2120
- UserProfileId columns for multi-user data isolation — all repos filter by active_profile_id
2221

22+
- Microsoft.Identity.Web v3.8.2 added to API for Entra ID JWT Bearer auth
23+
- Conditional auth pattern: `Auth:UseEntraId` config flag switches between Entra ID and DevAuthHandler
24+
- TenantContextMiddleware maps both Entra ID claims (tid, oid, name) and DevAuthHandler claims (tenant_id, NameIdentifier, Name) — Entra ID claims take precedence
25+
- appsettings.json is gitignored; use appsettings.Development.json for tracked config and AppHost env vars for runtime
26+
- Scope policies: `RequireScope("user.read")` etc. via Microsoft.Identity.Web authorization helpers
27+
- AzureAd public IDs (TenantId, ClientId, Audience) are NOT secrets — safe to commit
28+
2329
## Work Sessions
2430

2531
### 2026-03-13 — Cross-Agent Update: Azure Deployment Issues

.squad/agents/zoe/history.md

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -34,30 +34,3 @@
3434

3535
See `.squad/decisions.md` for full decision record.
3636

37-
### 2025-07-22 — Created GitHub Issues for Azure Deployment + Entra ID Plan
38-
39-
**Status:** Complete
40-
**Issues Created:** 27 issues (#39-#65)
41-
**Decision:** Reframed issue #39 (2.1) from "security emergency" to "best practices" — no secrets were committed to git history.
42-
43-
**Issue Mapping to Plan:**
44-
45-
- **Phase 1 (Auth):** #42 (Entra registrations) → #43 (JWT API) → #44 (WebApp OIDC) → #45 (MAUI MSAL) → #46 (CoreSync) → #47 (Integration tests)
46-
- **Phase 2 (Secrets):** #39 (user-secrets) → #40 (config all projects) → #41 (HTTPS/headers) → #54 (Key Vault integration)
47-
- **Phase 3 (Infrastructure):** #48 (azure.yaml) → #49 (PostgreSQL) → #50 (Redis) → #51 (Blob) → #52 (Container Apps) → #53 (Key Vault) → #55 (CoreSync DB migration)
48-
- **Phase 4 (Pipeline):** #56 (CI) → #57 (Deploy) → #58 (Staging) → #59 (Migrations)
49-
- **Phase 5 (Hardening):** #60 (Monitoring) → #61 (Rate limit) → #62 (CORS) → #63 (Health) → #64 (Scaling) → #65 (Audit logging)
50-
51-
**Team Assignments:**
52-
- Zoe (Lead): 14 issues (auth foundational work, infra decisions, hardening architecture)
53-
- Kaylee (Full-stack): 8 issues (WebApp OIDC, MAUI MSAL, CI/deploy workflows, monitoring)
54-
- Captain (David): 1 issue (#42 - requires Azure portal/Entra ID access)
55-
56-
**Dependencies Validated:** All 27 issues cross-referenced with dependency links. Phase order preserved for execution.
57-
58-
**Key Learnings:**
59-
- No security emergency: appsettings.json with secrets already in .gitignore
60-
- User-secrets workflow as team best practice (Phase 2.1)
61-
- Phase 1 testable entirely on localhost with Entra ID redirecting to `http://localhost`
62-
- CoreSync SQLite→PostgreSQL migration is critical path item (Phase 3.7, XL size)
63-
- Aspire-native provisioning via `azd` avoids manual Bicep maintenance
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Decision: JWT Bearer Authentication for API (#43)
2+
3+
**Date:** 2026-03-13
4+
**Author:** Wash (Backend Dev)
5+
**Status:** IMPLEMENTED
6+
**Branch:** `feature/43-api-jwt-bearer`
7+
8+
## Context
9+
10+
The API used only `DevAuthHandler` — a hardcoded auth handler that injects fake claims for local development. Issue #43 requires adding real JWT Bearer authentication via Microsoft Entra ID while keeping DevAuthHandler for local dev.
11+
12+
## Decision
13+
14+
### Conditional Authentication via Config Flag
15+
16+
- `Auth:UseEntraId` (bool, default `false`) controls which auth scheme is active.
17+
- When `true`: Microsoft.Identity.Web validates JWT Bearer tokens against Entra ID.
18+
- When `false`: DevAuthHandler provides fake dev claims (existing behavior).
19+
20+
### Scope-Based Authorization Policies
21+
22+
Four policies defined matching the Entra ID app registration scopes:
23+
- `RequireUserRead``user.read`
24+
- `RequireUserWrite``user.write`
25+
- `RequireAiAccess``ai.access`
26+
- `RequireSyncReadWrite``sync.readwrite`
27+
28+
Endpoints currently use `.RequireAuthorization()` (any authenticated user). Scope-based policies are available for endpoints to opt into as needed.
29+
30+
### TenantContextMiddleware Dual Claim Mapping
31+
32+
The middleware now checks both Entra ID claims (`tid`, `oid`, `name`, `preferred_username`) and DevAuthHandler claims (`tenant_id`, `NameIdentifier`, `Name`, `Email`). Entra ID claims take precedence.
33+
34+
### AzureAd Configuration
35+
36+
Public IDs (tenant, client, audience) are in `appsettings.Development.json` (tracked in git). The `appsettings.json` file is gitignored. AppHost also passes these as environment variables.
37+
38+
## Files Changed
39+
40+
| File | Change |
41+
|------|--------|
42+
| `SentenceStudio.Api.csproj` | Added `Microsoft.Identity.Web` v3.8.2 |
43+
| `Program.cs` | Conditional auth registration, scope policies |
44+
| `TenantContextMiddleware.cs` | Dual claim mapping (Entra ID + Dev) |
45+
| `appsettings.Development.json` | AzureAd section with public IDs |
46+
| `appsettings.json` | AzureAd section (local only, gitignored) |
47+
| `AppHost.cs` | AzureAd env vars passed to API service |
48+
49+
## Consequences
50+
51+
- **No breaking change** — defaults to DevAuthHandler (`UseEntraId: false`)
52+
- **Ready for production** — flip `Auth:UseEntraId` to `true` and tokens are validated
53+
- **Single-tenant**`AzureADMyOrg` via Microsoft.Identity.Web defaults
54+
- **Next steps:** Apply scope policies to specific endpoints (#44+), add MAUI client MSAL (#45)

src/SentenceStudio.Api/Auth/TenantContextMiddleware.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,26 @@ public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext)
1616
{
1717
if (tenantContext is TenantContext mutableContext && context.User.Identity?.IsAuthenticated == true)
1818
{
19-
mutableContext.TenantId = context.User.FindFirstValue("tenant_id");
20-
mutableContext.UserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
21-
mutableContext.DisplayName = context.User.FindFirstValue(ClaimTypes.Name);
22-
mutableContext.Email = context.User.FindFirstValue(ClaimTypes.Email);
19+
// Entra ID uses "tid" for tenant, "oid" for object/user ID,
20+
// and "preferred_username" or "name" for display info.
21+
// DevAuthHandler uses "tenant_id", NameIdentifier, Name, Email.
22+
// We check both sets so both auth paths populate TenantContext.
23+
mutableContext.TenantId =
24+
context.User.FindFirstValue("tid")
25+
?? context.User.FindFirstValue("tenant_id");
26+
27+
mutableContext.UserId =
28+
context.User.FindFirstValue("oid")
29+
?? context.User.FindFirstValue(ClaimTypes.NameIdentifier);
30+
31+
mutableContext.DisplayName =
32+
context.User.FindFirstValue("name")
33+
?? context.User.FindFirstValue("preferred_username")
34+
?? context.User.FindFirstValue(ClaimTypes.Name);
35+
36+
mutableContext.Email =
37+
context.User.FindFirstValue(ClaimTypes.Email)
38+
?? context.User.FindFirstValue("preferred_username");
2339
}
2440

2541
await _next(context);

src/SentenceStudio.Api/Program.cs

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Microsoft.AspNetCore.Authentication;
99
using Microsoft.AspNetCore.Mvc;
1010
using Microsoft.Extensions.AI;
11+
using Microsoft.Identity.Web;
1112
using OpenAI;
1213
using SentenceStudio.Api.Auth;
1314
using SentenceStudio.Contracts.Ai;
@@ -27,9 +28,37 @@
2728
// Add services to the container.
2829
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
2930
builder.Services.AddOpenApi();
30-
builder.Services.AddAuthentication(DevAuthHandler.SchemeName)
31-
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(DevAuthHandler.SchemeName, _ => { });
32-
builder.Services.AddAuthorization();
31+
var useEntraId = builder.Configuration.GetValue<bool>("Auth:UseEntraId");
32+
33+
if (useEntraId)
34+
{
35+
builder.Services.AddAuthentication(Microsoft.Identity.Web.Constants.Bearer)
36+
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
37+
38+
builder.Services.AddAuthorization(options =>
39+
{
40+
options.AddPolicy("RequireUserRead", policy =>
41+
policy.RequireScope("user.read"));
42+
options.AddPolicy("RequireUserWrite", policy =>
43+
policy.RequireScope("user.write"));
44+
options.AddPolicy("RequireAiAccess", policy =>
45+
policy.RequireScope("ai.access"));
46+
options.AddPolicy("RequireSyncReadWrite", policy =>
47+
policy.RequireScope("sync.readwrite"));
48+
});
49+
}
50+
else if (builder.Environment.IsDevelopment())
51+
{
52+
builder.Services.AddAuthentication(DevAuthHandler.SchemeName)
53+
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(DevAuthHandler.SchemeName, _ => { });
54+
builder.Services.AddAuthorization();
55+
}
56+
else
57+
{
58+
throw new InvalidOperationException(
59+
"Entra ID authentication must be enabled in non-development environments. Set Auth:UseEntraId=true.");
60+
}
61+
3362
builder.Services.AddScoped<ITenantContext, TenantContext>();
3463

3564
// CORS — basic policies for known callers.

src/SentenceStudio.Api/SentenceStudio.Api.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<PackageReference Include="ElevenLabs-DotNet" Version="3.7.1" />
1212
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
1313
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.2.0-preview.1.26063.2" />
14+
<PackageReference Include="Microsoft.Identity.Web" Version="3.8.2" />
1415
</ItemGroup>
1516

1617
<ItemGroup>

src/SentenceStudio.Api/appsettings.Development.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,14 @@
44
"Default": "Information",
55
"Microsoft.AspNetCore": "Warning"
66
}
7+
},
8+
"Auth": {
9+
"UseEntraId": false
10+
},
11+
"AzureAd": {
12+
"Instance": "https://login.microsoftonline.com/",
13+
"TenantId": "49c0cd14-bc68-4c6d-b87b-9d65a56fa6df",
14+
"ClientId": "8c051bcf-bd3a-4051-9cd3-0556ba5df2d8",
15+
"Audience": "api://8c051bcf-bd3a-4051-9cd3-0556ba5df2d8"
716
}
817
}

src/SentenceStudio.AppHost/AppHost.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
var syncfusionkey = builder.AddParameter("syncfusionkey", secret: true);
77
var elevenlabskey = builder.AddParameter("elevenlabskey", secret: true);
88

9+
var azureAdTenantId = builder.AddParameter("AzureAdTenantId", secret: false);
10+
var azureAdClientId = builder.AddParameter("AzureAdClientId", secret: false);
11+
var azureAdAudience = builder.AddParameter("AzureAdAudience", secret: false);
12+
913
var postgres = builder.AddPostgres("db")
1014
.AddDatabase("sentencestudio");
1115

@@ -17,7 +21,11 @@
1721

1822
var api = builder.AddProject<SentenceStudio_Api>("api")
1923
.WithEnvironment("AI__OpenAI__ApiKey", openaikey)
20-
.WithEnvironment("ElevenLabsKey", elevenlabskey);
24+
.WithEnvironment("ElevenLabsKey", elevenlabskey)
25+
.WithEnvironment("AzureAd__Instance", "https://login.microsoftonline.com/")
26+
.WithEnvironment("AzureAd__TenantId", azureAdTenantId)
27+
.WithEnvironment("AzureAd__ClientId", azureAdClientId)
28+
.WithEnvironment("AzureAd__Audience", azureAdAudience);
2129

2230
var web = builder.AddProject<SentenceStudio_Web>("web");
2331

0 commit comments

Comments
 (0)