yavsc/src/Yavsc.Tests.Shared/TestTokenIssuer.cs
Paul Schneider c3f2408c4a 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).
2026-07-06 23:29:51 +01:00

107 lines
4.2 KiB
C#

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);
}
}