using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Tiku.Application.Auth; using Tiku.Api.Contracts; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Commerce; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class AuthEndpointTests { [Fact] public async Task Sms_send_creates_login_code_without_exposing_it_and_rejects_platform_realm() { var provider = new CapturingSmsProvider(); await using var factory = new ApiTestFactory(smsProvider: provider); var tenantId = Guid.NewGuid(); await factory.SeedAsync(new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "SMS Tenant" }); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); var response = await client.PostAsJsonAsync( "/api/auth/sms/send", new SendSmsCodeDto { Realm = AuthRealm.Tenant, TenantCode = tenantId.ToString("N"), Phone = "13800000000", DeviceId = "sms-endpoint-device" }); var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); Assert.DoesNotContain(provider.Code!, body, StringComparison.Ordinal); Assert.Matches("^[0-9]{6}$", provider.Code!); using (var scope = factory.CreateSystemScope("Verify SMS send endpoint")) { var dbContext = scope.ServiceProvider.GetRequiredService(); var dimensions = await dbContext.SmsSendRateLimits .Select(item => item.Dimension) .ToArrayAsync(); Assert.Contains(SmsRateLimitDimension.Tenant, dimensions); Assert.Contains(SmsRateLimitDimension.Phone, dimensions); Assert.Contains(SmsRateLimitDimension.Device, dimensions); } client.DefaultRequestHeaders.Remove("x-tenant-code"); var platformResponse = await client.PostAsJsonAsync( "/api/auth/sms/send", new SendSmsCodeDto { Realm = AuthRealm.Platform, Phone = "13800000000", DeviceId = "sms-endpoint-device" }); Assert.Equal(HttpStatusCode.BadRequest, platformResponse.StatusCode); Assert.Equal(1, provider.SendCount); } [Fact] public async Task Custom_host_rejects_jwt_from_another_tenant_and_ignores_spoofed_tenant_header() { await using var factory = new ApiTestFactory(); var tenantB = await SeedLoginUserAsync(factory); var tenantAId = Guid.NewGuid(); await factory.SeedAsync( new Tenant { Id = tenantAId, Slug = "tenant-a", Name = "Tenant A", Status = TenantStatus.Active, Mode = TenantMode.Saas }, new TenantDomain { TenantId = tenantAId, Host = "a.example.test", DomainType = TenantDomainType.Custom, Status = TenantDomainStatus.Active, IsPrimary = true }); using var client = factory.CreateClient(); var tokens = await client.LoginAsTenantAsync(tenantB.TenantId, tenantB.Phone); using var jwtRequest = new HttpRequestMessage(HttpMethod.Get, "/api/me"); jwtRequest.Headers.Host = "a.example.test"; jwtRequest.Headers.Authorization = new("Bearer", tokens.AccessToken); var jwtResponse = await client.SendAsync(jwtRequest); using var spoofRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password"); spoofRequest.Headers.Host = "a.example.test"; spoofRequest.Headers.Add("x-tenant-code", tenantB.TenantId.ToString("N")); spoofRequest.Content = JsonContent.Create(new PasswordLoginDto { Realm = AuthRealm.Tenant, Phone = tenantB.Phone, Password = PasswordTestUserExtensions.TestPassword }); var spoofResponse = await client.SendAsync(spoofRequest); Assert.Equal(HttpStatusCode.Forbidden, jwtResponse.StatusCode); Assert.NotEqual(HttpStatusCode.OK, spoofResponse.StatusCode); } [Fact] public async Task Platform_authentication_artifacts_are_rejected_on_an_unconfigured_host() { await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["Tenancy:Resolution:ExemptPathPrefixes:3"] = "/api/auth" }); using var client = factory.CreateClient(); var refreshToken = $"v2.p.-.{Guid.NewGuid():N}.{new string('a', 86)}"; var requests = new[] { new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password") { Content = JsonContent.Create(new PasswordLoginDto { Realm = AuthRealm.Platform, Phone = "admin@example.com", Password = PasswordTestUserExtensions.TestPassword }) }, new HttpRequestMessage(HttpMethod.Post, "/api/auth/refresh") { Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken }) }, new HttpRequestMessage(HttpMethod.Post, "/api/auth/logout") { Content = JsonContent.Create(new RefreshSessionDto { RefreshToken = refreshToken }) } }; foreach (var request in requests) { using (request) { request.Headers.Host = "unconfigured.example.test"; using var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } } } [Fact] public async Task Password_login_can_access_current_user_and_tenant() { await using var factory = new ApiTestFactory(); var seed = await SeedLoginUserAsync(factory); using var client = factory.CreateClient(); var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone); client.UseAccessToken(tokens); var meResponse = await client.GetAsync("/api/me"); var tenantResponse = await client.GetAsync("/api/tenants/current"); Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, tenantResponse.StatusCode); Assert.Contains(seed.UserId.ToString(), await meResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); Assert.Contains(seed.TenantId.ToString(), await tenantResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Sms_login_can_access_current_user() { await using var factory = new ApiTestFactory(); var seed = await SeedLoginUserAsync(factory); await SeedSmsCodeAsync(factory, seed.TenantId, seed.Phone, "123456"); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N")); var loginResponse = await client.PostAsJsonAsync( "/api/auth/login/sms", new SmsLoginDto { Realm = AuthRealm.Tenant, TenantCode = seed.TenantId.ToString("N"), Phone = seed.Phone, Code = "123456" }); var tokens = await client.CompleteTenantAuthenticationAsync( loginResponse, seed.TenantId, seed.Phone); client.UseAccessToken(tokens); var meResponse = await client.GetAsync("/api/me"); Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); } [Fact] public async Task Logout_revokes_access_and_refresh_tokens() { await using var factory = new ApiTestFactory(); var seed = await SeedLoginUserAsync(factory); using var client = factory.CreateClient(); var tokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone); var logoutResponse = await client.PostAsJsonAsync( "/api/auth/logout", new RefreshSessionDto { RefreshToken = tokens.RefreshToken }); client.UseAccessToken(tokens); var meResponse = await client.GetAsync("/api/me"); var refreshResponse = await client.PostAsJsonAsync( "/api/auth/refresh", new RefreshSessionDto { RefreshToken = tokens.RefreshToken }); Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, meResponse.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, refreshResponse.StatusCode); } [Fact] public async Task Wechat_miniapp_login_can_access_current_user() { await using var factory = new ApiTestFactory(new FakeWechatOAuthClient()); var tenantId = Guid.NewGuid(); var secretRef = "tenant_secrets:identity:wechat_miniapp:default"; var protectedSecret = ProtectTenantSecret( tenantId, secretRef, JsonSerializer.SerializeToElement(new { appSecret = "wx-app-secret" })); await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Wechat Tenant" }, new TenantExternalProvider { TenantId = tenantId, Provider = "wechat_miniapp", Capability = TenantExternalProviderCapability.Identity, Status = TenantExternalProviderStatus.Active, SecretRef = secretRef, ConfigPublic = JsonSerializer.SerializeToElement(new { appId = "wx-app-id" }) }, new TenantSecret { TenantId = tenantId, Purpose = "identity", Provider = "wechat_miniapp", SecretKey = "default", SecretRef = secretRef, Status = TenantSecretStatus.Active, EncryptionKeyId = protectedSecret.KeyId, EncryptedPayload = protectedSecret.Ciphertext, EncryptionNonce = protectedSecret.Nonce, EncryptionTag = protectedSecret.Tag }); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); var loginResponse = await client.PostAsJsonAsync( "/api/auth/oauth/wechat-miniapp", new OAuthCodeDto { Realm = AuthRealm.Tenant, TenantCode = tenantId.ToString("N"), Code = "wx-code" }); var loginJson = await ReadJsonAsync(loginResponse); var accessToken = loginJson.RootElement .GetProperty("user") .GetProperty("tokens") .GetProperty("accessToken") .GetString(); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); var meResponse = await client.GetAsync("/api/me"); using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); Assert.Contains(dbContext.UserIdentities, identity => identity.Provider == "wechat_miniapp" && identity.OpenId == "mini-open-id" && identity.UnionId == "union-id"); var persistedUser = dbContext.Users.Single(user => dbContext.UserIdentities.Any(identity => identity.UserId == user.Id && identity.Provider == "wechat_miniapp")); Assert.DoesNotContain( "session_key", persistedUser.RawProfile.GetRawText(), StringComparison.OrdinalIgnoreCase); } private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync( ApiTestFactory factory) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13800000000"; await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Test Tenant" }, new User { Id = userId, Phone = phone, Name = "Test User" }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active }); return (tenantId, userId, phone); } private static async Task SeedSmsCodeAsync( ApiTestFactory factory, Guid tenantId, string phone, string code) { using var scope = factory.CreateSystemScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var smsOptions = scope.ServiceProvider.GetRequiredService>().Value; dbContext.SmsVerificationCodes.Add(new SmsVerificationCode { TenantId = tenantId, Phone = phone, Purpose = SmsPurpose.Login, CodeHash = SmsCodeHashing.Hash( tenantId, phone, SmsPurpose.Login, code, smsOptions.CodePepper), Status = SmsVerificationStatus.Sent, ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) }); await dbContext.SaveChangesAsync(); } private static async Task ReadJsonAsync(HttpResponseMessage response) { var stream = await response.Content.ReadAsStreamAsync(); return await JsonDocument.ParseAsync(stream); } private static ProtectedTenantSecret ProtectTenantSecret(Guid tenantId, string secretRef, JsonElement payload) { var protector = new TenantSecretProtector(Options.Create(new TenantSecretEncryptionOptions { KeyId = "development-v1", MasterKey = TenantSecretEncryptionOptions.DevelopmentMasterKey })); return protector.Protect(tenantId, secretRef, payload); } private sealed class FakeWechatOAuthClient : IWechatOAuthClient { public Task ExchangeWebCodeAsync( WechatProviderOptions options, string code, CancellationToken cancellationToken = default) { return Task.FromResult(new WechatIdentity( "web-open-id", "union-id", "Wechat User", "https://example.test/avatar.png", null, """{"openid":"web-open-id","unionid":"union-id"}""")); } public Task ExchangeMiniAppCodeAsync( WechatProviderOptions options, string code, CancellationToken cancellationToken = default) { return Task.FromResult(new WechatIdentity( "mini-open-id", "union-id", null, null, "session-key", """{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}""")); } } private sealed class CapturingSmsProvider : ISmsProvider { public int SendCount { get; private set; } public string? Code { get; private set; } public Task SendAsync( SmsProviderSendRequest request, CancellationToken cancellationToken = default) { SendCount++; Code = request.Code; return Task.FromResult(new SmsProviderSendResult("test", "sent", "sms-message-id")); } } }