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"). /// Value of the name claim. Defaults to /// (the test convention where the login /// equals the subject). Pass a distinct login to mirror production, /// where sub is a GUID and name is the user's /// UserName — the file-system list/upload paths key the /// personal-storage tree off the login, not the subject. /// Optional additional claims /// (e.g. a role for an admin-bypass test). public static string Issue( string subject, string scope = "blogs", string? name = null, IEnumerable? extraClaims = null) { var now = DateTime.UtcNow; var claims = new List { new("sub", subject), // Mirror production: IdentityServer's ProfileService emits the // "name" claim as the user's login (UserName) when the profile // scope is granted, and the file-system helpers resolve the // per-user storage root via GetUserName() (FindFirstValue("name")) // — not via User.Identity.Name. A test token without "name" would // make ReceiveUserFile / EnsureDestinationDirectory compute a // null owner root and 500 on upload, so stamp it here. new("name", name ?? 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); } }