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