From 0d3fbf22c3e8e474b3464a39330522f80162781c Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 10 Aug 2026 18:34:01 +0100 Subject: [PATCH] GetUserId_reads_NameIdentifier_when_sub_was_mapped --- .../BlogApiMappedClaimsTests.cs | 156 ++++++++++++++++++ src/Yavsc.Blogs.Tests/BlogApiTests.cs | 14 ++ .../JwtClaimMappingCollection.cs | 8 + .../MappedClaimsBlogsWebServerFixture.cs | 109 ++++++++++++ src/Yavsc.Server/Helpers/UserHelpers.cs | 4 +- 5 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs create mode 100644 src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs create mode 100644 src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs diff --git a/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs new file mode 100644 index 00000000..f02a1b99 --- /dev/null +++ b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs @@ -0,0 +1,156 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Claims; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using Yavsc.Models; +using Yavsc.Models.Blog; +using Yavsc.Tests.Shared; + +namespace Yavsc.Blogs.Tests; + +[Collection("JwtClaimMapping")] +public sealed class BlogApiMappedClaimsTests : IClassFixture +{ + private readonly MappedClaimsBlogsWebServerFixture _fixture; + + public BlogApiMappedClaimsTests(MappedClaimsBlogsWebServerFixture fixture) + { + _fixture = fixture; + } + + private void ResetDatabase() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + } + + private HttpClient NewClient(string subject = "tester") + { + var http = new HttpClient + { + BaseAddress = new Uri(_fixture.Addresses.First()) + }; + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", + IssueMappedClaimsToken(subject)); + return http; + } + + private static string IssueMappedClaimsToken(string subject) + { + var now = DateTime.UtcNow; + var claims = new List + { + new("sub", subject), + new("scope", "blogs"), + }; + + var token = new JwtSecurityToken( + issuer: TestTokenIssuer.Issuer, + audience: TestTokenIssuer.Audience, + claims: claims, + notBefore: now, + expires: now.AddHours(1), + signingCredentials: new SigningCredentials( + TestTokenIssuer.SigningKey, + SecurityAlgorithms.HmacSha256)); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + [Fact] + public async Task PostBlog_with_mapped_sub_claim_sets_AuthorId_from_authenticated_user() + { + ResetDatabase(); + using var http = NewClient(subject: "mapped-user"); + + var draft = new BlogPost + { + Id = 0, + Title = "Billet JWT remappe", + AuthorId = "payload-attacker", + Article = "Contenu de test.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + + var response = await http.PostAsJsonAsync("/api/v1/blog", draft); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var created = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.Equal("mapped-user", created!.AuthorId); + } + + [Fact] + public async Task PutBlog_with_mapped_sub_claim_allows_owner_to_update() + { + ResetDatabase(); + using var http = NewClient(subject: "mapped-owner"); + + var createdResponse = await http.PostAsJsonAsync("/api/v1/blog", new BlogPost + { + Id = 0, + Title = "Billet à modifier", + AuthorId = "payload-attacker", + Article = "Contenu initial.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }); + + Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode); + var created = await createdResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + + var updateResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost + { + Id = created.Id, + Title = "Billet modifié", + AuthorId = created.AuthorId, + Article = "Contenu mis à jour.", + DateCreated = created.DateCreated, + DateModified = DateTime.UtcNow + }); + + Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode); + } + + [Fact] + public async Task PutBlog_with_mapped_sub_claim_rejects_non_owner() + { + ResetDatabase(); + using var ownerHttp = NewClient(subject: "mapped-owner"); + + var createdResponse = await ownerHttp.PostAsJsonAsync("/api/v1/blog", new BlogPost + { + Id = 0, + Title = "Billet protégé", + AuthorId = "payload-attacker", + Article = "Contenu initial.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }); + + Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode); + var created = await createdResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + + using var otherHttp = NewClient(subject: "mapped-other"); + var updateResponse = await otherHttp.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost + { + Id = created.Id, + Title = "Tentative de modification", + AuthorId = created.AuthorId, + Article = "Contenu non autorisé.", + DateCreated = created.DateCreated, + DateModified = DateTime.UtcNow + }); + + Assert.Equal(HttpStatusCode.Unauthorized, updateResponse.StatusCode); + } +} diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index bf0758c6..7e725e4f 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -1,10 +1,12 @@ using System.Net; using System.Net.Http; using System.Net.Http.Json; +using System.Security.Claims; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Yavsc.Models; using Yavsc.Models.Blog; +using Yavsc.Server.Helpers; using Yavsc.Tests.Shared; namespace Yavsc.Blogs.Tests; @@ -20,6 +22,7 @@ namespace Yavsc.Blogs.Tests; /// header (or sending a token signed with the wrong key) gets a /// 401 back from the framework. /// +[Collection("JwtClaimMapping")] public sealed class BlogApiTests : IClassFixture { private readonly BlogsWebServerFixture _fixture; @@ -180,6 +183,17 @@ public sealed class BlogApiTests : IClassFixture Assert.Equal("tester", doc.RootElement[0].GetProperty("authorId").GetString()); } + [Fact] + public void GetUserId_reads_NameIdentifier_when_sub_was_mapped() + { + var principal = new ClaimsPrincipal( + new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, "tester")], + authenticationType: "Bearer")); + + Assert.Equal("tester", principal.GetUserId()); + } + [Fact] public async Task GetBlog_returns_401_when_no_token_is_provided() { diff --git a/src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs b/src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs new file mode 100644 index 00000000..e141c0f3 --- /dev/null +++ b/src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs @@ -0,0 +1,8 @@ +using Xunit; + +namespace Yavsc.Blogs.Tests; + +[CollectionDefinition("JwtClaimMapping", DisableParallelization = true)] +public sealed class JwtClaimMappingCollection +{ +} diff --git a/src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs new file mode 100644 index 00000000..c9d95774 --- /dev/null +++ b/src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs @@ -0,0 +1,109 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using Yavsc.Blogs.Controllers; +using Yavsc.Models; +using Yavsc.Services; +using Yavsc.Tests.Shared; + +namespace Yavsc.Blogs.Tests; + +/// +/// Dedicated integration-test host that mirrors the production JWT +/// remapping behavior: MapInboundClaims remains enabled and the +/// default inbound map rewrites "sub" to ClaimTypes.NameIdentifier. +/// This is the closest in-process reproduction of the production +/// authentication surface for the blog API. +/// +public sealed class MappedClaimsBlogsWebServerFixture : IDisposable +{ + private readonly InMemoryDatabaseRoot _inMemoryRoot = new(); + private readonly Dictionary _savedInboundMap; + private readonly WebApplication _app; + + public MappedClaimsBlogsWebServerFixture() + { + _savedInboundMap = new Dictionary(JwtSecurityTokenHandler.DefaultInboundClaimTypeMap); + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap["sub"] = ClaimTypes.NameIdentifier; + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:5104"); + + builder.Services.AddDbContext(opt => + opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot)); + + builder.Services.AddSingleton(new NoopFileSystemAuthManager()); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddControllers() + .AddApplicationPart(typeof(BlogApiController).Assembly); + builder.Services.AddAuthorization(opt => + { + opt.AddPolicy("BlogScope", policy => + { + policy.RequireAuthenticatedUser() + .RequireClaim("scope", "blogs"); + }); + }); + builder.Services.AddAuthentication("Bearer") + .AddJwtBearer("Bearer", options => + { + options.IncludeErrorDetails = true; + options.MapInboundClaims = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = TestTokenIssuer.Issuer, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = TestTokenIssuer.SigningKey, + RoleClaimType = YavscConstants.RoleClaimType, + NameClaimType = YavscConstants.NameClaimType, + }; + }); + + _app = builder.Build(); + _app.UseRouting(); + _app.UseAuthentication(); + _app.UseAuthorization(); + _app.MapControllers(); + _app.StartAsync().GetAwaiter().GetResult(); + + Addresses = ["http://127.0.0.1:5104"]; + Services = _app.Services; + } + + public IReadOnlyList Addresses { get; } + + public IServiceProvider Services { get; } + + public void Dispose() + { + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().AsTask().GetAwaiter().GetResult(); + + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + foreach (var kvp in _savedInboundMap) + { + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap[kvp.Key] = kvp.Value; + } + } + + private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager + { + public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath) + => FileAccessRight.None; + + public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access) + { + } + } +} diff --git a/src/Yavsc.Server/Helpers/UserHelpers.cs b/src/Yavsc.Server/Helpers/UserHelpers.cs index 105c1beb..c3ee708d 100644 --- a/src/Yavsc.Server/Helpers/UserHelpers.cs +++ b/src/Yavsc.Server/Helpers/UserHelpers.cs @@ -32,7 +32,9 @@ namespace Yavsc.Server.Helpers public static string GetUserId(this ClaimsPrincipal user) { - return user.FindFirstValue("sub"); + return user.FindFirstValue("sub") + ?? user.FindFirstValue(ClaimTypes.NameIdentifier) + ?? user.FindFirstValue("nameid"); } public static string GetUserName(this ClaimsPrincipal user)