using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Mvc.Testing; using Tiku.Api.Contracts; using Tiku.Api.Options; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; namespace Tiku.IntegrationTests.Api; public sealed class BrowserAuthenticationTests { [Fact] public async Task Password_login_uses_secure_cookies_and_does_not_return_tokens() { await using var factory = new ApiTestFactory(); var tenantId = Guid.NewGuid(); const string phone = "13890000000"; var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "Browser User" }.WithTestPassword(); await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Browser Tenant" }, user, new TenantMembership { TenantId = tenantId, UserId = user.Id, Role = TenantRole.Student, Status = MembershipStatus.Active }); using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); client.DefaultRequestHeaders.Add("Origin", "http://localhost"); var response = await client.PostAsJsonAsync("/api/tenant/auth/browser/login/password", new PasswordLoginDto { Realm = AuthRealm.Tenant, TenantCode = tenantId.ToString("N"), Identifier = phone, Password = PasswordTestUserExtensions.TestPassword }); var body = await response.Content.ReadAsStringAsync(); using var json = JsonDocument.Parse(body); var cookies = response.Headers.GetValues("Set-Cookie").ToArray(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.DoesNotContain("accessToken", body, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("refreshToken", body, StringComparison.OrdinalIgnoreCase); Assert.Equal("Authenticated", json.RootElement.GetProperty("status").GetString()); Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.AccessCookie) && value.Contains("secure", StringComparison.OrdinalIgnoreCase) && value.Contains("httponly", StringComparison.OrdinalIgnoreCase)); Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.RefreshCookie) && value.Contains("samesite=strict", StringComparison.OrdinalIgnoreCase)); Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.CsrfCookie)); } [Fact] public async Task Browser_refresh_requires_same_origin_and_matching_csrf_token() { await using var factory = new ApiTestFactory(); var tenantId = Guid.NewGuid(); const string phone = "13890000001"; var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "CSRF User" }.WithTestPassword(); await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "CSRF Tenant" }, user, new TenantMembership { TenantId = tenantId, UserId = user.Id, Role = TenantRole.Student, Status = MembershipStatus.Active }); using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); using var login = new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/browser/login/password") { Content = JsonContent.Create(new PasswordLoginDto { Realm = AuthRealm.Tenant, TenantCode = tenantId.ToString("N"), Identifier = phone, Password = PasswordTestUserExtensions.TestPassword }) }; login.Headers.Add("Origin", "http://localhost"); var loginResponse = await client.SendAsync(login); var setCookies = loginResponse.Headers.GetValues("Set-Cookie").ToArray(); var refresh = ReadCookie(setCookies, BrowserAuthOptions.RefreshCookie); var csrf = ReadCookie(setCookies, BrowserAuthOptions.CsrfCookie); var cookieHeader = $"{BrowserAuthOptions.RefreshCookie}={refresh}; {BrowserAuthOptions.CsrfCookie}={csrf}"; var missingCsrf = await SendRefreshAsync(client, cookieHeader, "http://localhost", null); var wrongOrigin = await SendRefreshAsync(client, cookieHeader, "https://attacker.example", csrf); var accepted = await SendRefreshAsync(client, cookieHeader, "http://localhost", csrf); Assert.Equal(HttpStatusCode.Forbidden, missingCsrf.StatusCode); Assert.Equal(HttpStatusCode.Forbidden, wrongOrigin.StatusCode); Assert.Equal(HttpStatusCode.OK, accepted.StatusCode); Assert.Contains(accepted.Headers.GetValues("Set-Cookie"), value => value.StartsWith(BrowserAuthOptions.RefreshCookie, StringComparison.Ordinal)); } [Fact] public async Task Access_cookie_is_ignored_without_same_origin_evidence() { await using var factory = new ApiTestFactory(); var tenantId = Guid.NewGuid(); const string phone = "13890000002"; var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "Cookie User" }.WithTestPassword(); await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Cookie Tenant" }, user, new TenantMembership { TenantId = tenantId, UserId = user.Id, Role = TenantRole.Student, Status = MembershipStatus.Active }); using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); using var login = new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/browser/login/password") { Content = JsonContent.Create(new PasswordLoginDto { Realm = AuthRealm.Tenant, TenantCode = tenantId.ToString("N"), Identifier = phone, Password = PasswordTestUserExtensions.TestPassword }) }; login.Headers.Add("Origin", "http://localhost"); var loginResponse = await client.SendAsync(login); var access = ReadCookie(loginResponse.Headers.GetValues("Set-Cookie").ToArray(), BrowserAuthOptions.AccessCookie); using var crossSite = new HttpRequestMessage(HttpMethod.Get, "/api/tenant/me"); crossSite.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}"); crossSite.Headers.Add("Sec-Fetch-Site", "cross-site"); var rejected = await client.SendAsync(crossSite); using var sameOrigin = new HttpRequestMessage(HttpMethod.Get, "/api/tenant/me"); sameOrigin.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}"); sameOrigin.Headers.Add("Origin", "http://localhost"); var accepted = await client.SendAsync(sameOrigin); Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode); Assert.Equal(HttpStatusCode.OK, accepted.StatusCode); } private static async Task SendRefreshAsync( HttpClient client, string cookieHeader, string origin, string? csrf) { using var request = new HttpRequestMessage(HttpMethod.Post, "/api/tenant/auth/browser/refresh"); request.Headers.Add("Cookie", cookieHeader); request.Headers.Add("Origin", origin); if (csrf is not null) request.Headers.Add(BrowserAuthOptions.CsrfHeader, csrf); return await client.SendAsync(request); } private static string ReadCookie(IEnumerable setCookies, string name) { var prefix = name + "="; var header = setCookies.Single(value => value.StartsWith(prefix, StringComparison.Ordinal)); return header[prefix.Length..header.IndexOf(';')]; } }