test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
This commit is contained in:
parent
6d222cf819
commit
c3f2408c4a
5 changed files with 328 additions and 24 deletions
|
|
@ -9,6 +9,8 @@
|
||||||
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.1.0-alpha.171" />
|
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.1.0-alpha.171" />
|
||||||
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
|
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||||
|
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
|
||||||
|
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
|
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" />
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,13 @@ namespace Yavsc.Blogs.Tests;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Behavioural tests for <c>BlogApiController</c>. Built on the
|
/// Behavioural tests for <c>BlogApiController</c>. Built on the
|
||||||
/// <see cref="BlogsWebServerFixture"/> scaffold: in-memory
|
/// <see cref="BlogsWebServerFixture"/> scaffold: in-memory
|
||||||
/// <c>ApplicationDbContext</c>, real <c>BlogSpotService</c>,
|
/// <c>ApplicationDbContext</c>, real <c>BlogSpotService</c>, and a
|
||||||
/// <c>X-Test-Role</c> for the <c>[Authorize("BlogScope")]</c>
|
/// real <c>AddJwtBearer</c> validating HS256 tokens signed by
|
||||||
/// attribute.
|
/// <see cref="TestTokenIssuer"/>. The production <c>BlogScope</c>
|
||||||
|
/// policy runs unmodified — sending <c>Authorization: Bearer …</c>
|
||||||
|
/// with a valid token is what gets a request through, omitting the
|
||||||
|
/// header (or sending a token signed with the wrong key) gets a
|
||||||
|
/// 401 back from the framework.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
{
|
{
|
||||||
|
|
@ -47,7 +51,13 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
private string BlogsUrl =>
|
private string BlogsUrl =>
|
||||||
_fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog";
|
_fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog";
|
||||||
|
|
||||||
private HttpClient NewClient()
|
/// <summary>Build an authenticated client: a real
|
||||||
|
/// <c>Authorization: Bearer <jwt></c> header where the JWT
|
||||||
|
/// is signed by <see cref="TestTokenIssuer"/> and carries
|
||||||
|
/// <c>sub = subject</c>. The production <c>BlogScope</c> policy
|
||||||
|
/// reads <c>scope=blogs</c> off the same token, so
|
||||||
|
/// <c>TestTokenIssuer.Issue</c>'s default scope is enough.</summary>
|
||||||
|
private HttpClient NewClient(string subject = "tester")
|
||||||
{
|
{
|
||||||
// The fixture's self-signed certificate is not in the user's
|
// The fixture's self-signed certificate is not in the user's
|
||||||
// trust store, so we accept anything (same pattern as
|
// trust store, so we accept anything (same pattern as
|
||||||
|
|
@ -60,10 +70,27 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
{
|
{
|
||||||
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||||
};
|
};
|
||||||
http.DefaultRequestHeaders.Add(TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole);
|
http.DefaultRequestHeaders.Authorization =
|
||||||
|
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||||
|
"Bearer", TestTokenIssuer.Issue(subject));
|
||||||
return http;
|
return http;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Build an unauthenticated client. Used to assert that
|
||||||
|
/// the <c>BlogScope</c> policy fails closed when no bearer
|
||||||
|
/// token is presented.</summary>
|
||||||
|
private HttpClient NewAnonymousClient()
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||||
|
};
|
||||||
|
return new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetBlogs_returns_200_with_empty_list_when_no_posts()
|
public async Task GetBlogs_returns_200_with_empty_list_when_no_posts()
|
||||||
{
|
{
|
||||||
|
|
@ -120,4 +147,99 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||||
Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64());
|
Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetBlog_returns_401_when_no_token_is_provided()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
using var http = NewAnonymousClient();
|
||||||
|
|
||||||
|
// No Authorization header → the JwtBearer middleware
|
||||||
|
// produces an unauthenticated principal, the BlogScope
|
||||||
|
// policy's RequireAuthenticatedUser requirement fails, and
|
||||||
|
// the framework returns 401. This is the proof that the
|
||||||
|
// production policy is wired in the test host and not
|
||||||
|
// short-circuited by a test-only auth bypass.
|
||||||
|
var response = await http.GetAsync("/api/v1/blog");
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PutBlog_with_valid_token_and_owner_returns_204_and_Get_reflects_update()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
// The JWT's sub must match the post's AuthorId:
|
||||||
|
// PermissionHandler.IsOwner checks blog.AuthorId == user.GetUserId(),
|
||||||
|
// and UserHelpers.GetUserId reads "sub" off the principal.
|
||||||
|
// A mismatched sub → AuthorizationFailureException →
|
||||||
|
// Challenge() (401) from the controller. The 204 in this
|
||||||
|
// test is the proof that the real authorization chain
|
||||||
|
// accepted the request, end-to-end.
|
||||||
|
using var http = NewClient(subject: "tester");
|
||||||
|
|
||||||
|
// Seed a post we can update.
|
||||||
|
var draft = new BlogPost
|
||||||
|
{
|
||||||
|
Id = 0,
|
||||||
|
Title = "Avant",
|
||||||
|
AuthorId = "tester",
|
||||||
|
Article = "Contenu initial.",
|
||||||
|
DateCreated = DateTime.UtcNow,
|
||||||
|
DateModified = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
|
||||||
|
|
||||||
|
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>())!;
|
||||||
|
|
||||||
|
// PUT with the server-issued Id; the controller rejects
|
||||||
|
// mismatched id/blog.Id with 400, so we keep them aligned.
|
||||||
|
var update = new BlogPost
|
||||||
|
{
|
||||||
|
Id = created.Id,
|
||||||
|
Title = "Après",
|
||||||
|
AuthorId = created.AuthorId,
|
||||||
|
Article = created.Article,
|
||||||
|
DateCreated = created.DateCreated,
|
||||||
|
DateModified = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
var putResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created.Id}", update);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
|
||||||
|
|
||||||
|
// The list should now reflect the new title.
|
||||||
|
var listResponse = await http.GetAsync("/api/v1/blog");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||||
|
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync());
|
||||||
|
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||||
|
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||||
|
Assert.Equal("Après", doc.RootElement[0].GetProperty("title").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
|
||||||
|
{
|
||||||
|
ResetDatabase();
|
||||||
|
using var http = NewClient();
|
||||||
|
|
||||||
|
// Seed a post we can delete.
|
||||||
|
var draft = new BlogPost
|
||||||
|
{
|
||||||
|
Id = 0,
|
||||||
|
Title = "À supprimer",
|
||||||
|
AuthorId = "tester",
|
||||||
|
Article = "Contenu.",
|
||||||
|
DateCreated = DateTime.UtcNow,
|
||||||
|
DateModified = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft);
|
||||||
|
var created = (await postResponse.Content.ReadFromJsonAsync<BlogPost>())!;
|
||||||
|
|
||||||
|
var deleteResponse = await http.DeleteAsync($"/api/v1/blog/{created.Id}");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
|
||||||
|
|
||||||
|
// The list should now be empty.
|
||||||
|
var listResponse = await http.GetAsync("/api/v1/blog");
|
||||||
|
using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync());
|
||||||
|
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
using System.Security.Claims;
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Yavsc.Blogs.Controllers;
|
using Yavsc.Blogs.Controllers;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Services;
|
using Yavsc.Services;
|
||||||
|
|
@ -22,12 +25,21 @@ namespace Yavsc.Blogs.Tests;
|
||||||
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
|
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
|
||||||
/// stub: the GET index path doesn't read the file system, so any
|
/// stub: the GET index path doesn't read the file system, so any
|
||||||
/// implementation is fine.</description></item>
|
/// implementation is fine.</description></item>
|
||||||
/// <item><description>The default <see cref="IAuthorizationService"/>
|
/// <item><description>The real <c>BlogSpotService</c>, which calls
|
||||||
/// from <c>Microsoft.AspNetCore.Authorization</c>.</description></item>
|
/// <c>IAuthorizationService.AuthorizeAsync(user, blog, new EditPermission())</c>
|
||||||
/// <item><description>The test auth bypass from
|
/// on PUT. The fixture registers the real
|
||||||
/// <c>Yavsc.Tests.Shared</c> so the <c>[Authorize("BlogScope")]</c>
|
/// <see cref="PermissionHandler"/> so the resource-based ownership
|
||||||
/// attribute on <c>BlogApiController</c> is satisfied when the
|
/// check runs end-to-end; tests that want a 204 PUT must sign a
|
||||||
/// test sends the <c>X-Test-Role</c> header.</description></item>
|
/// JWT whose <c>sub</c> matches the post's <c>AuthorId</c>.</description></item>
|
||||||
|
/// <item><description>A real <c>AddJwtBearer</c> with HS256,
|
||||||
|
/// sharing its <see cref="TestTokenIssuer.SigningKey"/> with the
|
||||||
|
/// token issuer. The production OIDC discovery path is bypassed:
|
||||||
|
/// the test host validates tokens locally, against the static
|
||||||
|
/// signing key, so no IdP is required to exercise auth.</description></item>
|
||||||
|
/// <item><description>The production <c>BlogScope</c> policy
|
||||||
|
/// (RequireAuthenticatedUser + RequireClaim("scope", "blogs"))
|
||||||
|
/// registered verbatim. Tests that omit the bearer header exercise
|
||||||
|
/// the unauthenticated path and get 401.</description></item>
|
||||||
/// </list>
|
/// </list>
|
||||||
///
|
///
|
||||||
/// No IdentityServer, no SMTP, no static assets — the Org fixture
|
/// No IdentityServer, no SMTP, no static assets — the Org fixture
|
||||||
|
|
@ -36,6 +48,8 @@ namespace Yavsc.Blogs.Tests;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BlogsWebServerFixture : WebHostFixture
|
public sealed class BlogsWebServerFixture : WebHostFixture
|
||||||
{
|
{
|
||||||
|
private InMemoryDatabaseRoot? _inMemoryRoot;
|
||||||
|
|
||||||
protected override WebApplication BuildApp(WebApplicationBuilder builder)
|
protected override WebApplication BuildApp(WebApplicationBuilder builder)
|
||||||
{
|
{
|
||||||
// Use the real ApplicationDbContext with an in-memory store.
|
// Use the real ApplicationDbContext with an in-memory store.
|
||||||
|
|
@ -43,8 +57,16 @@ public sealed class BlogsWebServerFixture : WebHostFixture
|
||||||
// attempt to mock it would be wasted work; the real service
|
// attempt to mock it would be wasted work; the real service
|
||||||
// against an empty table returns an empty list, which is
|
// against an empty table returns an empty list, which is
|
||||||
// exactly what the first test wants to assert.
|
// exactly what the first test wants to assert.
|
||||||
|
//
|
||||||
|
// Share a single InMemoryDatabaseRoot across the test
|
||||||
|
// lifetime so POST + GET on the same fixture see the same
|
||||||
|
// store. Without the root, EF Core's In-Memory provider
|
||||||
|
// creates independent stores per DbContext in some
|
||||||
|
// configurations, and the second request would see an
|
||||||
|
// empty list even after the first wrote a row.
|
||||||
|
_inMemoryRoot = new InMemoryDatabaseRoot();
|
||||||
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
|
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
|
||||||
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests"));
|
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests", _inMemoryRoot));
|
||||||
|
|
||||||
// Trivial file-system auth: the GET index path never calls
|
// Trivial file-system auth: the GET index path never calls
|
||||||
// into it, but the DI container needs an instance.
|
// into it, but the DI container needs an instance.
|
||||||
|
|
@ -53,28 +75,77 @@ public sealed class BlogsWebServerFixture : WebHostFixture
|
||||||
|
|
||||||
// Real BlogSpotService — same instance the production host
|
// Real BlogSpotService — same instance the production host
|
||||||
// builds (ApplicationDbContext, IAuthorizationService,
|
// builds (ApplicationDbContext, IAuthorizationService,
|
||||||
// IFileSystemAuthManager).
|
// IFileSystemAuthManager). With PermissionHandler registered
|
||||||
|
// below, Modify() now answers "is the caller the author of
|
||||||
|
// the post?" for real, which is exactly what we want to
|
||||||
|
// assert in the PUT tests.
|
||||||
builder.Services.AddScoped<BlogSpotService>();
|
builder.Services.AddScoped<BlogSpotService>();
|
||||||
|
|
||||||
|
// The real PermissionHandler: BlogSpotService calls
|
||||||
|
// IAuthorizationService.AuthorizeAsync(user, blog, new
|
||||||
|
// EditPermission()) on Modify, and PermissionHandler
|
||||||
|
// resolves it via IsOwner(user, blog) — i.e. blog.AuthorId
|
||||||
|
// == user.GetUserId(). To PUT a post, the test JWT must
|
||||||
|
// carry sub == post.AuthorId.
|
||||||
|
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
||||||
|
|
||||||
// The BlogApiController is reached through MVC. AddControllers()
|
// The BlogApiController is reached through MVC. AddControllers()
|
||||||
// by default scans the test assembly only; we explicitly add the
|
// by default scans the test assembly only; we explicitly add the
|
||||||
// Yavsc.Blogs application part so the controller is discovered
|
// Yavsc.Blogs application part so the controller is discovered
|
||||||
// and routed.
|
// and routed.
|
||||||
builder.Services.AddControllers()
|
builder.Services.AddControllers()
|
||||||
.AddApplicationPart(typeof(BlogApiController).Assembly);
|
.AddApplicationPart(typeof(BlogApiController).Assembly);
|
||||||
|
|
||||||
|
// Production BlogScope policy, verbatim. Two requirements:
|
||||||
|
// 1. RequireAuthenticatedUser: a request with no bearer
|
||||||
|
// token (or an invalid one) will be rejected.
|
||||||
|
// 2. RequireClaim("scope", "blogs"): the JWT must carry a
|
||||||
|
// "scope" claim whose value is "blogs".
|
||||||
|
// TestTokenIssuer.Issue() defaults to scope=blogs; the
|
||||||
|
// GetBlog_returns_401_when_no_token test omits the token
|
||||||
|
// entirely and asserts the policy fails closed.
|
||||||
builder.Services.AddAuthorization(opt =>
|
builder.Services.AddAuthorization(opt =>
|
||||||
{
|
{
|
||||||
// Mirror the production "BlogScope" policy: any
|
opt.AddPolicy("BlogScope", policy =>
|
||||||
// authenticated user. The TestAuthPolicyProvider we
|
{
|
||||||
// register below short-circuits the role check via the
|
policy.RequireAuthenticatedUser()
|
||||||
// X-Test-Role header.
|
.RequireClaim("scope", "blogs");
|
||||||
opt.AddPolicy("BlogScope", p => p.RequireAssertion(_ => true));
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test auth bypass — swapped in BEFORE the host builds the
|
// Real JWT Bearer authentication, sharing the signing key
|
||||||
// service collection, so it overrides any production
|
// with TestTokenIssuer. No Authority → no OIDC discovery,
|
||||||
// policy provider registered by AddAuthorization above.
|
// no IdP roundtrip; the middleware validates the signature
|
||||||
builder.Services.AddSingleton<IAuthorizationPolicyProvider, TestAuthPolicyProvider>();
|
// and the standard claims against the static configuration
|
||||||
|
// below. Production uses AddYavscJwtBearer with an IdP, but
|
||||||
|
// for the unit-test host that path is unwanted coupling.
|
||||||
|
builder.Services.AddAuthentication("Bearer")
|
||||||
|
.AddJwtBearer("Bearer", options =>
|
||||||
|
{
|
||||||
|
options.IncludeErrorDetails = true;
|
||||||
|
// MapInboundClaims = false here mirrors the
|
||||||
|
// JwtSecurityTokenHandler.DefaultInboundClaimTypeMap
|
||||||
|
// .Clear() in TestTokenIssuer: the validation
|
||||||
|
// pipeline must not rewrite "sub" to
|
||||||
|
// ClaimTypes.NameIdentifier, otherwise the
|
||||||
|
// PermissionHandler ownership check sees a null
|
||||||
|
// user id and rejects every PUT.
|
||||||
|
options.MapInboundClaims = false;
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = TestTokenIssuer.Issuer,
|
||||||
|
ValidateAudience = false,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = TestTokenIssuer.SigningKey,
|
||||||
|
// "sub" stays "sub" (MapInboundClaims only
|
||||||
|
// remaps long Microsoft claim URIs, not sub).
|
||||||
|
// UserHelpers.GetUserId reads sub directly.
|
||||||
|
NameClaimType = "sub",
|
||||||
|
RoleClaimType = YavscConstants.RoleClaimType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return builder.Build();
|
return builder.Build();
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +165,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
|
||||||
/// file system, so the implementation can be a no-op.</summary>
|
/// file system, so the implementation can be a no-op.</summary>
|
||||||
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
|
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
|
||||||
{
|
{
|
||||||
public FileAccessRight GetFilePathAccess(ClaimsPrincipal user, string fileRelativePath)
|
public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath)
|
||||||
=> FileAccessRight.None;
|
=> FileAccessRight.None;
|
||||||
|
|
||||||
public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access)
|
public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access)
|
||||||
|
|
|
||||||
107
src/Yavsc.Tests.Shared/TestTokenIssuer.cs
Normal file
107
src/Yavsc.Tests.Shared/TestTokenIssuer.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Yavsc.Tests.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mints HS256-signed JWTs for integration tests. The signing key is
|
||||||
|
/// held in a static field shared with the test host's
|
||||||
|
/// <c>AddJwtBearer</c> registration: whatever the host validates
|
||||||
|
/// against, this issuer signs with.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// HS256 (symmetric) is the right choice for a unit-test issuer:
|
||||||
|
/// no key generation ceremony, no PEM round-trip, no asymmetric
|
||||||
|
/// crypto on the hot path. The key never leaves the test process.
|
||||||
|
/// Production continues to validate against the OIDC authority via
|
||||||
|
/// <c>AddYavscJwtBearer</c> — this issuer is *only* for the
|
||||||
|
/// in-process test host.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class TestTokenIssuer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Symmetric signing key shared with the test host's
|
||||||
|
/// <c>TokenValidationParameters.IssuerSigningKey</c>.
|
||||||
|
/// 32 bytes of zeros is enough entropy for HS256 *within the test
|
||||||
|
/// process*; the assertion we care about is "does the policy
|
||||||
|
/// evaluate a properly-signed token", not "is the key unguessable
|
||||||
|
/// by an attacker" (there is no attacker here).
|
||||||
|
/// </summary>
|
||||||
|
public static readonly SymmetricSecurityKey SigningKey =
|
||||||
|
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('k', 32)));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issuer stamped into the <c>iss</c> claim and checked by the
|
||||||
|
/// test host. Must match
|
||||||
|
/// <c>TokenValidationParameters.ValidIssuer</c>.
|
||||||
|
/// </summary>
|
||||||
|
public const string Issuer = "yavsc-test-issuer";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audience stamped into the <c>aud</c> claim. The test host
|
||||||
|
/// does not validate audience (production may), so this is here
|
||||||
|
/// for shape only.
|
||||||
|
/// </summary>
|
||||||
|
public const string Audience = "yavsc-test";
|
||||||
|
|
||||||
|
private static bool _inboundClaimTypeMapCleared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mint a JWT carrying the given <paramref name="subject"/> as
|
||||||
|
/// the <c>sub</c> claim, a single <c>scope</c> claim with value
|
||||||
|
/// <paramref name="scope"/>, and any additional
|
||||||
|
/// <paramref name="extraClaims"/>. Token is valid for one hour
|
||||||
|
/// from now.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="subject">Value of the <c>sub</c> claim. Read
|
||||||
|
/// back by <c>UserHelpers.GetUserId</c>, which is how
|
||||||
|
/// <c>PermissionHandler.IsOwner</c> identifies the author of a
|
||||||
|
/// <c>BlogPost</c> on PUT.</param>
|
||||||
|
/// <param name="scope">Value of the <c>scope</c> claim. The
|
||||||
|
/// production <c>BlogScope</c> policy requires
|
||||||
|
/// <c>RequireClaim("scope", "blogs")</c>.</param>
|
||||||
|
/// <param name="extraClaims">Optional additional claims
|
||||||
|
/// (e.g. a role for an admin-bypass test).</param>
|
||||||
|
public static string Issue(
|
||||||
|
string subject,
|
||||||
|
string scope = "blogs",
|
||||||
|
IEnumerable<Claim>? extraClaims = null)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new("sub", subject),
|
||||||
|
new("scope", scope),
|
||||||
|
};
|
||||||
|
if (extraClaims is not null) claims.AddRange(extraClaims);
|
||||||
|
|
||||||
|
// JwtSecurityTokenHandler ships with a static
|
||||||
|
// DefaultInboundClaimTypeMap that rewrites short JWT claim
|
||||||
|
// names to their long Microsoft URIs at deserialisation
|
||||||
|
// time. The most relevant rewrite for us is
|
||||||
|
// "sub" → ClaimTypes.NameIdentifier. Without clearing the
|
||||||
|
// map, UserHelpers.GetUserId() — which reads the literal
|
||||||
|
// "sub" claim — would not find the value, PermissionHandler
|
||||||
|
// .IsOwner would compare against null, and the controller
|
||||||
|
// would return 401 on every PUT. Clearing is the standard
|
||||||
|
// way to opt out of the legacy mapping.
|
||||||
|
if (!_inboundClaimTypeMapCleared)
|
||||||
|
{
|
||||||
|
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
|
||||||
|
_inboundClaimTypeMapCleared = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var creds = new SigningCredentials(SigningKey, SecurityAlgorithms.HmacSha256);
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: Issuer,
|
||||||
|
audience: Audience,
|
||||||
|
claims: claims,
|
||||||
|
notBefore: now,
|
||||||
|
expires: now.AddHours(1),
|
||||||
|
signingCredentials: creds);
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,5 +16,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Hosting" />
|
<PackageReference Include="Microsoft.AspNetCore.Hosting" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||||
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue