ASP.NET Core服务:测试用硬编码公钥验证JWT,生产从身份提供商取公钥
解决方案推荐
针对你的需求,这里提供两种实用方案,既能复用生产环境的JWT认证配置,又能满足集成测试时自定义JWT的需求,同时保证测试覆盖生产代码。
方案一:抽象公钥获取逻辑,通过依赖注入切换环境实现
核心思路是定义一个公钥获取的抽象接口,生产环境从身份提供商的OIDC元数据自动拉取公钥,测试环境注入自定义公钥,复用同一套AddJwtBearer配置。
1. 定义公钥获取接口
public interface IPublicKeyProvider { Task<IEnumerable<SecurityKey>> GetSigningKeysAsync(CancellationToken cancellationToken = default); }
2. 生产环境实现(从OIDC元数据拉取公钥)
public class OidcPublicKeyProvider : IPublicKeyProvider { private readonly string _authority; private readonly IHttpClientFactory _httpClientFactory; private IEnumerable<SecurityKey> _cachedKeys; private DateTime _cacheExpiry; public OidcPublicKeyProvider(string authority, IHttpClientFactory httpClientFactory) { _authority = authority; _httpClientFactory = httpClientFactory; } public async Task<IEnumerable<SecurityKey>> GetSigningKeysAsync(CancellationToken cancellationToken = default) { // 缓存公钥,避免频繁调用身份提供商接口 if (_cachedKeys != null && DateTime.UtcNow < _cacheExpiry) { return _cachedKeys; } var httpClient = _httpClientFactory.CreateClient(); // 获取OIDC元数据 var metadataResponse = await httpClient.GetStringAsync($"{_authority.TrimEnd('/')}/.well-known/openid-configuration", cancellationToken); var metadata = JsonSerializer.Deserialize<OidcMetadata>(metadataResponse); // 获取JWKS公钥集合 var jwksResponse = await httpClient.GetStringAsync(metadata.JwksUri, cancellationToken); var jwks = JsonWebKeySet.Create(jwksResponse); _cachedKeys = jwks.GetSigningKeys(); _cacheExpiry = DateTime.UtcNow.AddHours(24); // 缓存24小时,可按需调整 return _cachedKeys; } private class OidcMetadata { [JsonPropertyName("jwks_uri")] public string JwksUri { get; set; } } }
3. 测试环境实现(注入自定义公钥)
public class TestPublicKeyProvider : IPublicKeyProvider { private readonly SecurityKey _signingKey; public TestPublicKeyProvider(SecurityKey signingKey) { _signingKey = signingKey; } public Task<IEnumerable<SecurityKey>> GetSigningKeysAsync(CancellationToken cancellationToken = default) { return Task.FromResult(new[] { _signingKey }.AsEnumerable()); } }
4. 在Program.cs中配置依赖注入与JWT认证
var builder = WebApplication.CreateBuilder(args); // 注册HttpClientFactory用于生产环境拉取公钥 builder.Services.AddHttpClient(); // 根据环境注册对应的公钥提供器 if (builder.Environment.IsProduction() || builder.Environment.IsStaging()) { var authority = builder.Configuration["Auth:Endpoint"]; builder.Services.AddSingleton<IPublicKeyProvider>(sp => new OidcPublicKeyProvider(authority, sp.GetRequiredService<IHttpClientFactory>())); } else if (builder.Environment.IsEnvironment("IntegrationTest")) { // 测试环境注入自定义RSA公钥(可从测试配置读取或动态生成) var rsa = RSA.Create(); rsa.ImportFromPem("-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----"); var securityKey = new RsaSecurityKey(rsa); builder.Services.AddSingleton<IPublicKeyProvider>(new TestPublicKeyProvider(securityKey)); } // 复用核心JWT认证配置 JwtSecurityTokenHandler.DefaultMapInboundClaims = false; builder.Services .AddAuthentication(auth => { auth.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; auth.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => { var authority = builder.Configuration["Auth:Endpoint"]; var audience = builder.Configuration["JwtAuthentication:Audience"]; options.Audience = audience; options.TokenValidationParameters = new TokenValidationParameters { RoleClaimType = "role", NameClaimType = "sub", ValidateIssuer = !builder.Environment.IsEnvironment("IntegrationTest"), ValidIssuer = builder.Environment.IsEnvironment("IntegrationTest") ? "test-issuer" : authority, ValidateAudience = true, ValidAudience = audience }; // 动态加载签名密钥 options.Events = new JwtBearerEvents { OnTokenValidated = async context => { var keyProvider = context.HttpContext.RequestServices.GetRequiredService<IPublicKeyProvider>(); var signingKeys = await keyProvider.GetSigningKeysAsync(); context.SecurityTokenHandler.Configuration.SigningKeys.Clear(); foreach (var key in signingKeys) { context.SecurityTokenHandler.Configuration.SigningKeys.Add(key); } } }; // 生产环境保留Authority自动配置逻辑 if (!builder.Environment.IsEnvironment("IntegrationTest")) { options.Authority = authority; } });
5. 集成测试中生成自定义JWT
public class IntegrationTests { private TestServer _server; private HttpClient _client; private RSA _testRsa; [SetUp] public void Setup() { // 生成测试用RSA密钥对 _testRsa = RSA.Create(2048); var testSigningKey = new RsaSecurityKey(_testRsa); var builder = new WebHostBuilder() .UseEnvironment("IntegrationTest") .ConfigureServices(services => { // 替换为测试用公钥提供器 services.AddSingleton<IPublicKeyProvider>(new TestPublicKeyProvider(testSigningKey)); }) .UseStartup<Startup>(); _server = new TestServer(builder); _client = _server.CreateClient(); } [Test] public async Task ProtectedEndpoint_ValidTestJwt_ReturnsOk() { // 生成自定义测试JWT var tokenHandler = new JwtSecurityTokenHandler(); var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(new[] { new Claim("sub", "test-user"), new Claim("role", "admin") }), Expires = DateTime.UtcNow.AddHours(1), Issuer = "test-issuer", Audience = "your-audience", SigningCredentials = new SigningCredentials(new RsaSecurityKey(_testRsa), SecurityAlgorithms.RsaSha256) }; var token = tokenHandler.CreateToken(tokenDescriptor); var jwt = tokenHandler.WriteToken(token); // 请求受保护端点 _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jwt); var response = await _client.GetAsync("/api/protected"); response.EnsureSuccessStatusCode(); Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); } }
方案二:测试时启动内存版身份提供商
如果希望完全复用生产环境的Authority配置逻辑,可在测试中启动一个内存版的身份提供商(如IdentityServer4),让测试环境的认证流程与生产完全一致。
测试核心代码示例
public class IntegrationTests { private TestServer _webServer; private HttpClient _webClient; [SetUp] public void Setup() { // 启动内存版IdentityServer var identityServerBuilder = new WebHostBuilder() .UseStartup<TestIdentityServerStartup>(); var identityServer = new TestServer(identityServerBuilder); // 配置Web服务指向测试用IdentityServer var webBuilder = new WebHostBuilder() .UseEnvironment("IntegrationTest") .ConfigureAppConfiguration(config => { config.AddInMemoryCollection(new Dictionary<string, string> { ["Auth:Endpoint"] = identityServer.BaseAddress.ToString(), ["JwtAuthentication:Audience"] = "your-audience" }); }) .UseStartup<Startup>(); _webServer = new TestServer(webBuilder); _webClient = _webServer.CreateClient(); } // 测试用IdentityServer启动配置 public class TestIdentityServerStartup { public void ConfigureServices(IServiceCollection services) { var rsa = RSA.Create(2048); var signingKey = new RsaSecurityKey(rsa); services.AddIdentityServer() .AddInMemoryApiScopes(new[] { new ApiScope("your-audience") }) .AddInMemoryClients(new[] { new Client { ClientId = "test-client", AllowedGrantTypes = GrantTypes.ClientCredentials, ClientSecrets = { new Secret("test-secret".Sha256()) }, AllowedScopes = { "your-audience" } } }) .AddSigningCredential(signingKey); } public void Configure(IApplicationBuilder app) { app.UseIdentityServer(); } } [Test] public async Task ProtectedEndpoint_ValidTokenFromTestIdp_ReturnsOk() { // 从测试IdentityServer获取Token var tokenResponse = await _webClient.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest { Address = $"{_webServer.BaseAddress}connect/token", ClientId = "test-client", ClientSecret = "test-secret", Scope = "your-audience" }); // 请求受保护端点 _webClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken); var response = await _webClient.GetAsync("/api/protected"); response.EnsureSuccessStatusCode(); Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); } }
方案选择建议
- 若需要完全控制JWT的内容和签名逻辑,优先选择方案一,配置灵活且无需额外启动服务。
- 若希望测试流程与生产环境完全一致(包括从身份提供商获取公钥的逻辑),优先选择方案二,能覆盖完整的认证链路。
内容的提问来源于stack exchange,提问作者Claus Appel
相关产品推荐
相关产品推荐

