using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Yavsc.Tests.Shared;
///
/// Mints HS256-signed JWTs for integration tests. The signing key is
/// held in a static field shared with the test host's
/// AddJwtBearer registration: whatever the host validates
/// against, this issuer signs with.
///
///
/// 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
/// AddYavscJwtBearer — this issuer is *only* for the
/// in-process test host.
///
///
public static class TestTokenIssuer
{
///
/// Symmetric signing key shared with the test host's
/// TokenValidationParameters.IssuerSigningKey.
/// 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).
///
public static readonly SymmetricSecurityKey SigningKey =
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('k', 32)));
///
/// Issuer stamped into the iss claim and checked by the
/// test host. Must match
/// TokenValidationParameters.ValidIssuer.
///
public const string Issuer = "yavsc-test-issuer";
///
/// Audience stamped into the aud claim. The test host
/// does not validate audience (production may), so this is here
/// for shape only.
///
public const string Audience = "yavsc-test";
private static bool _inboundClaimTypeMapCleared;
///
/// Mint a JWT carrying the given as
/// the sub claim, a single scope claim with value
/// , and any additional
/// . Token is valid for one hour
/// from now.
///
/// Value of the sub claim. Read
/// back by UserHelpers.GetUserId, which is how
/// PermissionHandler.IsOwner identifies the author of a
/// BlogPost on PUT.
/// Value of the scope claim. The
/// production BlogScope policy requires
/// RequireClaim("scope", "blogs").
/// Optional additional claims
/// (e.g. a role for an admin-bypass test).
public static string Issue(
string subject,
string scope = "blogs",
IEnumerable? extraClaims = null)
{
var now = DateTime.UtcNow;
var claims = new List
{
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);
}
}