GetUserId_reads_NameIdentifier_when_sub_was_mapped
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled

This commit is contained in:
Paul Schneider 2026-08-10 18:34:01 +01:00
commit 0d3fbf22c3
No known key found for this signature in database
GPG key ID: 1E66C65EE2B46F1B
5 changed files with 290 additions and 1 deletions

View file

@ -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<MappedClaimsBlogsWebServerFixture>
{
private readonly MappedClaimsBlogsWebServerFixture _fixture;
public BlogApiMappedClaimsTests(MappedClaimsBlogsWebServerFixture fixture)
{
_fixture = fixture;
}
private void ResetDatabase()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
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<Claim>
{
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<BlogPost>();
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<BlogPost>();
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<BlogPost>();
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);
}
}

View file

@ -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.
/// </summary>
[Collection("JwtClaimMapping")]
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
@ -180,6 +183,17 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
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()
{

View file

@ -0,0 +1,8 @@
using Xunit;
namespace Yavsc.Blogs.Tests;
[CollectionDefinition("JwtClaimMapping", DisableParallelization = true)]
public sealed class JwtClaimMappingCollection
{
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class MappedClaimsBlogsWebServerFixture : IDisposable
{
private readonly InMemoryDatabaseRoot _inMemoryRoot = new();
private readonly Dictionary<string, string> _savedInboundMap;
private readonly WebApplication _app;
public MappedClaimsBlogsWebServerFixture()
{
_savedInboundMap = new Dictionary<string, string>(JwtSecurityTokenHandler.DefaultInboundClaimTypeMap);
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap["sub"] = ClaimTypes.NameIdentifier;
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:5104");
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot));
builder.Services.AddSingleton<IFileSystemAuthManager>(new NoopFileSystemAuthManager());
builder.Services.AddScoped<BlogSpotService>();
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
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<string> 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)
{
}
}
}

View file

@ -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)