diff --git a/backend/src/CodeSpace.Api/Controllers/AuthController.cs b/backend/src/CodeSpace.Api/Controllers/AuthController.cs index 50eeae6e0..5969db89d 100644 --- a/backend/src/CodeSpace.Api/Controllers/AuthController.cs +++ b/backend/src/CodeSpace.Api/Controllers/AuthController.cs @@ -1,6 +1,9 @@ +using CodeSpace.Core.Services.Auth; using CodeSpace.Messages.Commands.Accounts; using CodeSpace.Messages.Commands.Auth; using MediatR; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using CodeSpace.Api.Extensions; using Microsoft.AspNetCore.Mvc; @@ -20,22 +23,66 @@ public class AuthController : ControllerBase // IS the wire contract; with JsonStringEnumConverter wired globally + X-Team-Id moving // to the request header, there's no field on these commands the client shouldn't see. + // Every endpoint that mints a session hands it to the browser in the HttpOnly session cookie, which its scripts never + // see (SessionCookie), and to a caller that keeps it itself — an API client, the CLI — in the body as well. Never in + // the body to a request the cookie carried: that caller may be a script in the page, and it could read a body. + [HttpPost("sign-in")] [AllowAnonymous] public async Task SignIn([FromBody] SignInCommand command, CancellationToken cancellationToken) { var result = await _mediator.Send(command, cancellationToken).ConfigureAwait(false); - return Ok(result); + + SessionCookie.Issue(HttpContext, result.Token!, result.ExpiresAt); + + return Ok(SessionCookie.Authenticated(HttpContext) ? result with { Token = null } : result); } // change-password requires the bearer token (the user proves they are who they say // they are by also presenting CurrentPassword). The MediatR pipeline lets it through // even when password_must_change=true via the IBypassPasswordRotationGuard marker. + // The change rotates the stamp, killing the cookie this request may have ridden in on, so the + // browser is handed the session minted under the new one. [HttpPost("change-password")] public async Task ChangePassword([FromBody] ChangePasswordCommand command, CancellationToken cancellationToken) { var result = await _mediator.Send(command, cancellationToken).ConfigureAwait(false); - return Ok(result); + + SessionCookie.Issue(HttpContext, result.Token!, result.ExpiresAt); + + return Ok(SessionCookie.Authenticated(HttpContext) ? result with { Token = null } : result); + } + + // The browser's session as a resource. No mediator: authentication has already happened by the time an action runs, + // and what is left is setting or clearing a cookie, which is HTTP and nothing else. All three require a live + // session (the fallback policy), and the two writes, ridden in on the cookie, require its CSRF header. + + /// "Am I signed in?" — answered by whatever credential the request carries. The SPA asks without a header, so a 204 proves the browser holds and sends the cookie. + [HttpGet("session")] + public IActionResult ProbeSession() => NoContent(); + + /// + /// Moves the presented token into the session cookie: how a browser signed in before the cookie existed trades the + /// token its scripts held for one they cannot. It is the SAME token — same expiry, same stamp — so the exchange can + /// never extend a session, and it never appears in the response body. + /// + [HttpPost("session")] + public async Task EstablishSession() + { + var token = await HttpContext.GetTokenAsync(JwtBearerDefaults.AuthenticationScheme, "access_token").ConfigureAwait(false); + + SessionCookie.Issue(HttpContext, token!, BearerTokenLifetime.ExpiresAt(User)!.Value); + + return NoContent(); + } + + /// Signs this browser out by clearing its cookie. Other sessions stay live: ending every session is what changing the password does. + [HttpDelete("session")] + public IActionResult EndSession() + { + SessionCookie.Clear(HttpContext); + + return NoContent(); } /// diff --git a/backend/src/CodeSpace.Api/Controllers/InvitationsController.cs b/backend/src/CodeSpace.Api/Controllers/InvitationsController.cs index af742b65a..2c0c6ad62 100644 --- a/backend/src/CodeSpace.Api/Controllers/InvitationsController.cs +++ b/backend/src/CodeSpace.Api/Controllers/InvitationsController.cs @@ -1,3 +1,4 @@ +using CodeSpace.Core.Services.Auth; using CodeSpace.Messages.Commands.Invitations; using CodeSpace.Messages.Queries.Invitations; using MediatR; @@ -35,12 +36,19 @@ public async Task Preview([FromRoute] string token, CancellationT return Ok(result); } - /// Spends the invitation and answers with a session, so the invitee lands signed in. + /// + /// Spends the invitation and answers with a session, so the invitee lands signed in: as the browser's HttpOnly session + /// cookie, and in the body too unless the cookie carried the request. A signed-in caller accepts as itself, and any + /// account can invite any address, so a page script accepting an invitation its author sent would otherwise read a + /// fresh session for the person whose page it runs in. + /// [HttpPost("{token}/accept")] public async Task Accept([FromRoute] string token, [FromBody] AcceptInvitationCommand command, CancellationToken cancellationToken) { var result = await _mediator.Send(command with { Token = token }, cancellationToken).ConfigureAwait(false); - return Ok(result); + SessionCookie.Issue(HttpContext, result.Token!, result.ExpiresAt); + + return Ok(SessionCookie.Authenticated(HttpContext) ? result with { Token = null } : result); } } diff --git a/backend/src/CodeSpace.Api/Extensions/AuthenticationExtension.cs b/backend/src/CodeSpace.Api/Extensions/AuthenticationExtension.cs index 1287c57ae..e2e0e9a5e 100644 --- a/backend/src/CodeSpace.Api/Extensions/AuthenticationExtension.cs +++ b/backend/src/CodeSpace.Api/Extensions/AuthenticationExtension.cs @@ -33,11 +33,16 @@ public static void AddCustomAuthentication(this IServiceCollection services, ICo IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)) }; + // Kept on the request (never persisted) so POST /api/auth/session can hand the browser the very token + // it authenticated with, rather than minting a longer-lived one. + options.SaveToken = true; + // Signature and lifetime say the token was minted by us and has not aged out. Neither // says the account behind it is still live, and a stateless token cannot: that is a // fact only the server holds. Checking it HERE covers every authenticated request, - // including the streaming endpoints and anything that never reaches the mediator. - options.Events = new JwtBearerEvents { OnTokenValidated = RejectDeadSessionsAsync }; + // including the streaming endpoints and anything that never reaches the mediator — + // and whichever carrier, header or cookie, the token arrived in. + options.Events = new JwtBearerEvents { OnMessageReceived = ReadSessionCookie, OnTokenValidated = RejectDeadSessionsAsync }; }); services.AddAuthorization(options => @@ -50,6 +55,26 @@ public static void AddCustomAuthentication(this IServiceCollection services, ICo }); } + /// + /// A browser carries its session in , which no page script can read, rather than in a + /// header. Read only when the request has no Authorization header, so every header-carrying client authenticates + /// exactly as before. A write the cookie alone would authorize must also carry ; + /// without it the cookie is not believed, and the request is as anonymous as one another site could have sent. + /// + private static Task ReadSessionCookie(MessageReceivedContext context) + { + var token = SessionCookie.ReadToken(context.Request); + + if (token == null) return Task.CompletedTask; + + if (SessionCookie.IsUnguardedWrite(context.Request)) + context.Fail($"A {context.Request.Method} authenticated by the session cookie must carry the {SessionCookie.CsrfHeader} header."); + else + context.Token = token; + + return Task.CompletedTask; + } + private static async Task RejectDeadSessionsAsync(TokenValidatedContext context) { var subject = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier); diff --git a/backend/src/CodeSpace.Api/Extensions/CorsPolicyExtension.cs b/backend/src/CodeSpace.Api/Extensions/CorsPolicyExtension.cs index 25d763253..adad9ca19 100644 --- a/backend/src/CodeSpace.Api/Extensions/CorsPolicyExtension.cs +++ b/backend/src/CodeSpace.Api/Extensions/CorsPolicyExtension.cs @@ -24,13 +24,13 @@ public static IServiceCollection AddCorsPolicy(this IServiceCollection services, services.AddCors(options => options.AddPolicy(PolicyName, policy => policy .WithOrigins(origins) - .AllowAnyHeader() // the SPA sends Authorization + X-Team-Id + Content-Type + .AllowAnyHeader() // the SPA sends X-CodeSpace-Csrf + X-Team-Id + Content-Type (+ Authorization while on a pre-cookie token) .AllowAnyMethod() // GET/POST/PATCH/DELETE + the OPTIONS preflight // AllowAnyHeader governs REQUEST headers only; a custom RESPONSE header stays unreadable cross-origin // unless it is exposed. The SPA reads the current-team, bounded log-range, and bounded event-data contracts, so expose them // from the same constants the server emits — the readable names can never drift from the written names. .WithExposedHeaders([HeaderCurrentTeam.HeaderName, .. AgentRunLogHttpHeaders.RangeResponseHeaders, .. AgentRunEventDataHttpHeaders.RangeResponseHeaders, .. RunRecordPayloadHttpHeaders.RangeResponseHeaders]) - .AllowCredentials())); // no cookies today, but keeps a refresh-cookie flow open + .AllowCredentials())); // the browser session is a cookie (SessionCookie): without this a cross-origin SPA neither sends it nor keeps it return services; } diff --git a/backend/src/CodeSpace.Core/Services/Auth/BearerTokenLifetime.cs b/backend/src/CodeSpace.Core/Services/Auth/BearerTokenLifetime.cs index 705c84118..8bb1b3f42 100644 --- a/backend/src/CodeSpace.Core/Services/Auth/BearerTokenLifetime.cs +++ b/backend/src/CodeSpace.Core/Services/Auth/BearerTokenLifetime.cs @@ -19,8 +19,9 @@ public static class BearerTokenLifetime /// including its exp plus . False for a principal with no readable exp, which no /// token the handler lets through lacks. /// - public static bool Accepts(ClaimsPrincipal? principal, DateTimeOffset now) => ReadExpiry(principal) is { } expiresAt && now <= expiresAt + ClockSkew; + public static bool Accepts(ClaimsPrincipal? principal, DateTimeOffset now) => ExpiresAt(principal) is { } expiresAt && now <= expiresAt + ClockSkew; - private static DateTimeOffset? ReadExpiry(ClaimsPrincipal? principal) => + /// The principal's token exp, or null when it carries no readable one. Also how long a browser session cookie holding that token may live. + public static DateTimeOffset? ExpiresAt(ClaimsPrincipal? principal) => long.TryParse(principal?.FindFirst(JwtRegisteredClaimNames.Exp)?.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) ? DateTimeOffset.FromUnixTimeSeconds(seconds) : null; } diff --git a/backend/src/CodeSpace.Core/Services/Auth/SessionCookie.cs b/backend/src/CodeSpace.Core/Services/Auth/SessionCookie.cs new file mode 100644 index 000000000..1a84a6cc1 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Auth/SessionCookie.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using HeaderNames = Microsoft.Net.Http.Headers.HeaderNames; + +namespace CodeSpace.Core.Services.Auth; + +/// +/// The browser's session: the same JWT a bearer client holds, carried in an HttpOnly cookie so that no script on the +/// page can read it. A token in localStorage is one injected script away from leaving the browser for a day; a +/// token the page never sees cannot be exfiltrated by the page. +/// +/// The bearer handler reads it only when a request carries no Authorization header, so every API client, +/// the CLI, MCP and the test suite keep authenticating exactly as before, and a browser cookie riding along beside a +/// header never changes who the caller is. +/// +/// A cookie is sent by the browser whoever asked for the request, which a header never was. Three things keep a +/// request another site starts from riding it: SameSite=Strict, so the browser does not attach it to a request +/// another site initiates; , which a cookie-authenticated write must carry and which neither a +/// form nor a preflight-free fetch can set; and the API's CORS allow-list, which is the only thing that can grant a +/// foreign origin that header. +/// +public static class SessionCookie +{ + /// Pinned by unit test. Not __Host--prefixed: that prefix demands Secure and Path=/, and this cookie has neither on a plain-HTTP deployment and wants the narrower path. + public const string Name = "codespace_session"; + + /// Only API requests need the session. The SPA's own pages never receive it, and neither does /hangfire, which keeps its own write surface off this cookie. + public const string Path = "/api"; + + /// + /// The header the SPA sends on every request. A write authenticated by the cookie alone is refused without it: no HTML + /// form can set a header, and a cross-origin fetch that sets one is preflighted, which the CORS allow-list answers. + /// Pinned by unit test — the SPA and any proxy rule name it. + /// + public const string CsrfHeader = "X-CodeSpace-Csrf"; + + /// Hands the browser the session , to expire with the token itself. + public static void Issue(HttpContext context, string token, DateTimeOffset expiresAt) => context.Response.Cookies.Append(Name, token, Options(context, expiresAt - ClockOf(context).GetUtcNow())); + + /// Tells the browser to drop the session cookie. Written with the attributes it was set with, or the browser treats it as a different cookie and keeps the original. + public static void Clear(HttpContext context) => context.Response.Cookies.Delete(Name, Options(context, maxAge: null)); + + /// + /// The token the bearer handler should validate from the cookie: null when the request carries an Authorization + /// header (the header is the caller's choice and is read first, even when it fails) or no session cookie at all. + /// + public static string? ReadToken(HttpRequest request) => request.Headers.ContainsKey(HeaderNames.Authorization) ? null : NullIfEmpty(request.Cookies[Name]); + + /// + /// True when the caller was authenticated by the cookie: signed in, with no Authorization header the session + /// could have come from instead. Such a caller may be a script running in the page, which can send the cookie and + /// alike, so an endpoint that mints a session hands it to this caller ONLY as the cookie — + /// never in a body that script would read. + /// + public static bool Authenticated(HttpContext context) => context.User.Identity?.IsAuthenticated == true && ReadToken(context.Request) != null; + + /// True for a request that would change something on the strength of the cookie alone: any method not known to be safe, without . + public static bool IsUnguardedWrite(HttpRequest request) => !IsSafeMethod(request.Method) && string.IsNullOrWhiteSpace(request.Headers[CsrfHeader]); + + /// + /// Secure exactly when the request arrived over HTTPS — the effective scheme, after whatever forwarded-headers + /// handling the host applies. A browser discards a Secure cookie set over plain HTTP, Safari on http://localhost too, + /// so an unconditional Secure would leave every plain-HTTP deployment unable to hold a session (the SPA would then + /// stay on its bearer token, which is how it degrades, but needlessly). + /// + private static CookieOptions Options(HttpContext context, TimeSpan? maxAge) => new() + { + HttpOnly = true, + Secure = context.Request.IsHttps, + SameSite = SameSiteMode.Strict, + Path = Path, + MaxAge = maxAge, + IsEssential = true, + }; + + private static bool IsSafeMethod(string method) => HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method) || HttpMethods.IsTrace(method); + + private static TimeProvider ClockOf(HttpContext context) => context.RequestServices?.GetService() ?? TimeProvider.System; + + private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; +} diff --git a/backend/src/CodeSpace.Messages/Commands/Auth/ChangePasswordCommand.cs b/backend/src/CodeSpace.Messages/Commands/Auth/ChangePasswordCommand.cs index 24d6ea7bd..a1cfffd94 100644 --- a/backend/src/CodeSpace.Messages/Commands/Auth/ChangePasswordCommand.cs +++ b/backend/src/CodeSpace.Messages/Commands/Auth/ChangePasswordCommand.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using CodeSpace.Messages.Authorization; using CodeSpace.Messages.Dtos.Users; using CodeSpace.Messages.Mediation; @@ -24,9 +25,11 @@ public sealed record ChangePasswordResponse /// /// A token minted under the NEW security stamp. Changing a password ends every session for the /// account, including the one that asked — without handing this back, the act of rotating would - /// sign the rotator out. + /// sign the rotator out. Absent from the wire when the request was authenticated by the browser's session cookie, + /// as on : that caller gets the new session as the replacement cookie alone. /// - public required string Token { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required string? Token { get; init; } public required DateTimeOffset ExpiresAt { get; init; } } diff --git a/backend/src/CodeSpace.Messages/Commands/Auth/SignInCommand.cs b/backend/src/CodeSpace.Messages/Commands/Auth/SignInCommand.cs index 098e9fccc..c2b24ac19 100644 --- a/backend/src/CodeSpace.Messages/Commands/Auth/SignInCommand.cs +++ b/backend/src/CodeSpace.Messages/Commands/Auth/SignInCommand.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using CodeSpace.Messages.Authorization; using CodeSpace.Messages.Dtos.Users; using CodeSpace.Messages.Mediation; @@ -23,7 +24,13 @@ public sealed record SignInCommand : ICommand, IBypassPasswordRo public sealed record SignInResponse { - public required string Token { get; init; } + /// + /// The session, for a caller that keeps it itself — an API client, the CLI. Absent from the wire when the request + /// was authenticated by the browser's session cookie: the response hands that caller its session as the cookie + /// alone, because a body is readable by any script running in the page. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public required string? Token { get; init; } public required DateTimeOffset ExpiresAt { get; init; } public required MeResponse User { get; init; } } diff --git a/backend/tests/CodeSpace.E2ETests/Auth/InvitationEndpointE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Auth/InvitationEndpointE2ETests.cs index c12ef7563..1d9a005b3 100644 --- a/backend/tests/CodeSpace.E2ETests/Auth/InvitationEndpointE2ETests.cs +++ b/backend/tests/CodeSpace.E2ETests/Auth/InvitationEndpointE2ETests.cs @@ -3,6 +3,7 @@ using System.Text.Json; using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Auth; using CodeSpace.E2ETests.Infrastructure; using CodeSpace.Messages.Constants; using CodeSpace.Messages.Enums; @@ -49,6 +50,12 @@ public async Task Accepting_over_http_creates_the_account_and_returns_a_session( body.GetProperty("token").GetString().ShouldNotBeNullOrWhiteSpace("the invitee must land signed in, not at a login form"); body.GetProperty("user").GetProperty("email").GetString().ShouldBe(email); + // The browser lands signed in by the HttpOnly session cookie, carrying the same session the body hands an API client. + var cookie = (response.Headers.TryGetValues("Set-Cookie", out var setCookies) ? setCookies : []).SingleOrDefault(c => c.StartsWith($"{SessionCookie.Name}=", StringComparison.Ordinal)); + cookie.ShouldNotBeNull("accepting an invitation mints a browser session, so it must hand the browser its cookie"); + cookie.ShouldStartWith($"{SessionCookie.Name}={body.GetProperty("token").GetString()};"); + cookie.ToLowerInvariant().ShouldContain("httponly"); + // The shipped page reads user.teams to pick which team to land in. body.GetProperty("user").GetProperty("teams").GetArrayLength().ShouldBeGreaterThanOrEqualTo(2, "the invited team plus the personal workspace acceptance creates"); } diff --git a/backend/tests/CodeSpace.E2ETests/Auth/SessionCookieE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Auth/SessionCookieE2ETests.cs new file mode 100644 index 000000000..6fd1c19be --- /dev/null +++ b/backend/tests/CodeSpace.E2ETests/Auth/SessionCookieE2ETests.cs @@ -0,0 +1,357 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Auth; +using CodeSpace.E2ETests.Infrastructure; +using CodeSpace.Messages.Constants; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace CodeSpace.E2ETests.Auth; + +/// +/// The browser session on the wire: a sign-in hands back an HttpOnly cookie no page script can read, the cookie alone +/// is a session for the API, a write riding it needs the header another site cannot set, a header-carrying client is +/// untouched by any of it, and every way a session ends — signing out, changing the password, deactivation — ends +/// the cookie's. +/// +/// Tier: 🟢 High-fidelity — the real app host, real Postgres, the real middleware pipeline (CORS, the bearer +/// handler and its events, authorization, the controllers). Every assertion reads what a browser would receive or +/// sends what a browser would send. No cookie jar: each request carries exactly the cookie the test names. +/// +[Trait("Category", "E2E")] +[Trait("Surface", "Http")] +public sealed class SessionCookieE2ETests : IClassFixture +{ + private const string Password = "correct-horse-battery"; + + private readonly TaskLaunchApiFactory _factory; + private readonly TenantHarness _tenants; + + public SessionCookieE2ETests(TaskLaunchApiFactory factory) + { + _factory = factory; + _tenants = new TenantHarness(factory); + } + + [Theory] + [InlineData("https", true)] + [InlineData("http", false)] + public async Task Signing_in_hands_the_browser_an_http_only_strict_cookie_that_is_secure_exactly_over_https(string scheme, bool secure) + { + var account = await AccountAsync(); + + var signIn = await _tenants.Client(new Uri($"{scheme}://localhost")).PostAsJsonAsync("/api/auth/sign-in", new { name = account.Email, password = Password }); + + signIn.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(signIn)); + + var cookie = SessionCookieOf(signIn).ShouldNotBeNull($"a sign-in must hand the browser its session — Set-Cookie: {string.Join(" | ", SetCookies(signIn))}"); + + HasAttribute(cookie, "httponly").ShouldBeTrue("no script on the page may read the session"); + HasAttribute(cookie, "samesite=strict").ShouldBeTrue("no request another site starts may carry it"); + HasAttribute(cookie, "path=/api").ShouldBeTrue("only the API needs it"); + HasAttribute(cookie, "secure").ShouldBe(secure, $"over {scheme} — Set-Cookie: {cookie}"); + MaxAgeOf(cookie).ShouldBeInRange(TimeSpan.FromHours(24) - TimeSpan.FromMinutes(1), TimeSpan.FromHours(24), "the cookie lives exactly as long as the token it carries"); + + (await TokenInBodyAsync(signIn)).ShouldBe(CookieValue(cookie), "API clients still read the token from the body; the cookie carries that same session"); + } + + [Fact] + public async Task The_cookie_alone_is_a_session_and_no_cookie_is_none() + { + var account = await AccountAsync(); + var cookie = await SignInAsync(account.Email); + + (await SendAsync(HttpMethod.Get, "/api/users/me", cookie: cookie)).StatusCode.ShouldBe(HttpStatusCode.OK, "a GET needs nothing but the cookie"); + (await SendAsync(HttpMethod.Get, "/api/auth/session", cookie: cookie)).StatusCode.ShouldBe(HttpStatusCode.NoContent, "the probe the SPA asks 'am I signed in' with"); + (await SendAsync(HttpMethod.Get, "/api/auth/session")).StatusCode.ShouldBe(HttpStatusCode.Unauthorized, "no cookie, no header: nobody"); + } + + [Theory] + [InlineData("POST", "/api/auth/session")] + [InlineData("PUT", "/api/teams/cost-cap")] + [InlineData("PATCH", "/api/teams/members/00000000-0000-0000-0000-000000000001")] + [InlineData("DELETE", "/api/auth/session")] + public async Task A_write_riding_the_cookie_must_carry_the_header_another_site_cannot_set(string method, string path) + { + var account = await AccountAsync(); + var cookie = await SignInAsync(account.Email); + + var forged = await SendAsync(new HttpMethod(method), path, cookie: cookie); + var fromTheApp = await SendAsync(new HttpMethod(method), path, cookie: cookie, csrf: true); + + forged.StatusCode.ShouldBe(HttpStatusCode.Unauthorized, $"{method} {path} with the cookie and no {SessionCookie.CsrfHeader} is what a form on another site sends — {await TenantHarness.DescribeAsync(forged)}"); + SessionCookieOf(forged).ShouldBeNull("a refused write changes nothing, the cookie included"); + fromTheApp.StatusCode.ShouldNotBe(HttpStatusCode.Unauthorized, $"the control: the same cookie with the header is a session — {await TenantHarness.DescribeAsync(fromTheApp)}"); + } + + [Fact] + public async Task A_bearer_client_writes_without_the_header_and_a_stray_cookie_never_changes_who_it_is() + { + // The CLI, MCP and every API client: an Authorization header, no CSRF header, and — on a developer's machine — + // possibly a browser's cookie for someone else in the same jar. + var browserUser = await AccountAsync(); + var apiClient = await AccountAsync(); + var strayCookie = await SignInAsync(browserUser.Email); + var bearer = await TokenInBodyAsync(await SignInResponseAsync(apiClient.Email)); + + var me = await SendAsync(HttpMethod.Get, "/api/users/me", cookie: strayCookie, bearer: bearer); + var write = await SendAsync(HttpMethod.Post, "/api/auth/change-password", cookie: strayCookie, bearer: bearer, body: new { currentPassword = Password, newPassword = "a-brand-new-passphrase" }); + + me.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(me)); + JsonDocument.Parse(await me.Content.ReadAsStringAsync()).RootElement.GetProperty("email").GetString().ShouldBe(apiClient.Email, "the header decides who the caller is"); + write.StatusCode.ShouldBe(HttpStatusCode.OK, $"a header-authenticated write needs no CSRF header — {await TenantHarness.DescribeAsync(write)}"); + } + + [Fact] + public async Task A_dead_header_is_not_rescued_by_a_live_cookie() + { + var account = await AccountAsync(); + var cookie = await SignInAsync(account.Email); + var deadBearer = TestToken.Mint(account.Id, Guid.NewGuid()); + + var response = await SendAsync(HttpMethod.Get, "/api/users/me", cookie: cookie, bearer: deadBearer); + + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized, "a caller that sent a header gets the header's answer"); + } + + [Fact] + public async Task Signing_out_clears_the_cookie_with_the_attributes_it_was_set_with() + { + var account = await AccountAsync(); + var cookie = await SignInAsync(account.Email); + + var signOut = await SendAsync(HttpMethod.Delete, "/api/auth/session", cookie: cookie, csrf: true); + + signOut.StatusCode.ShouldBe(HttpStatusCode.NoContent, await TenantHarness.DescribeAsync(signOut)); + + var cleared = SessionCookieOf(signOut).ShouldNotBeNull("the page cannot touch an HttpOnly cookie, so only the server can end it in the browser"); + + CookieValue(cleared).ShouldBeEmpty(); + HasAttribute(cleared, "expires=Thu, 01 Jan 1970 00:00:00 GMT").ShouldBeTrue(cleared); + HasAttribute(cleared, "path=/api").ShouldBeTrue("cleared at the path it was set on, or the browser keeps it"); + HasAttribute(cleared, "samesite=strict").ShouldBeTrue(cleared); + } + + /// + /// A page script rides the cookie and can send the CSRF header, so it can call anything that mints a session. Were + /// the minted token in the body, the script would read a portable 24-hour bearer out of it — the very theft the + /// cookie exists to stop. The concrete path: any account opens a workspace and invites the victim's address; the + /// victim's page, signed in, accepts as the victim, and the response mints the victim a token. So a request the + /// cookie carried gets its session back only as the cookie; a header-carrying client still reads it from the body. + /// + [Theory] + [InlineData("sign-in", true)] + [InlineData("sign-in", false)] + [InlineData("change-password", true)] + [InlineData("change-password", false)] + [InlineData("accept", true)] + [InlineData("accept", false)] + public async Task A_session_minted_for_a_request_the_cookie_carried_reaches_the_browser_only_as_the_cookie(string endpoint, bool ridesCookie) + { + var account = await AccountAsync(); + var session = await SignInAsync(account.Email); + var (path, body) = await SessionMintingRequestAsync(endpoint, account.Email); + + var response = ridesCookie ? await SendAsync(HttpMethod.Post, path, cookie: session, csrf: true, body: body) : await SendAsync(HttpMethod.Post, path, bearer: session, body: body); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, $"{endpoint} — {await TenantHarness.DescribeAsync(response)}"); + + var raw = await response.Content.ReadAsStringAsync(); + var minted = CookieValue(SessionCookieOf(response).ShouldNotBeNull($"{endpoint} still hands the browser its session as the HttpOnly cookie")); + + JsonDocument.Parse(raw).RootElement.TryGetProperty("token", out _).ShouldBe(!ridesCookie, $"{endpoint} riding the cookie={ridesCookie} — body: {raw}"); + + if (ridesCookie) raw.ShouldNotContain(minted, Case.Sensitive, "no form of the minted session may reach a body a page script reads"); + else JsonDocument.Parse(raw).RootElement.GetProperty("token").GetString().ShouldBe(minted, "a bearer client reads the session it was minted from the body, exactly as before"); + } + + [Fact] + public async Task Changing_the_password_through_the_cookie_replaces_it_and_kills_the_one_before() + { + var account = await AccountAsync(); + var before = await SignInAsync(account.Email); + + var changed = await SendAsync(HttpMethod.Post, "/api/auth/change-password", cookie: before, csrf: true, body: new { currentPassword = Password, newPassword = "a-brand-new-passphrase" }); + + changed.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(changed)); + + var after = CookieValue(SessionCookieOf(changed).ShouldNotBeNull("the change rotates the stamp, so the browser needs the session minted under the new one")); + + (await SendAsync(HttpMethod.Get, "/api/users/me", cookie: before)).StatusCode.ShouldBe(HttpStatusCode.Unauthorized, "the cookie minted before the change is dead"); + (await SendAsync(HttpMethod.Get, "/api/users/me", cookie: after)).StatusCode.ShouldBe(HttpStatusCode.OK, "the one handed back with the change is live"); + } + + [Fact] + public async Task Deactivating_the_account_ends_its_cookie_session_on_the_next_request() + { + var account = await AccountAsync(); + var cookie = await SignInAsync(account.Email); + + (await SendAsync(HttpMethod.Get, "/api/users/me", cookie: cookie)).StatusCode.ShouldBe(HttpStatusCode.OK, "the control"); + + using (var scope = _factory.Services.CreateScope()) + await scope.ServiceProvider.GetRequiredService().DeactivateAsync(account.Id, CancellationToken.None); + + (await SendAsync(HttpMethod.Get, "/api/users/me", cookie: cookie)).StatusCode.ShouldBe(HttpStatusCode.Unauthorized, "the same cookie, one write later"); + } + + [Fact] + public async Task A_pre_cookie_token_is_exchanged_for_a_cookie_carrying_that_same_token() + { + // A browser signed in before the cookie existed holds its token in localStorage. It trades it, once, for the + // cookie. The SAME token: minting a fresh one here would turn the exchange into a way to extend a stolen token. + var account = await AccountAsync(); + var bearer = await TokenInBodyAsync(await SignInResponseAsync(account.Email)); + + var exchange = await SendAsync(HttpMethod.Post, "/api/auth/session", bearer: bearer); + + exchange.StatusCode.ShouldBe(HttpStatusCode.NoContent, await TenantHarness.DescribeAsync(exchange)); + (await exchange.Content.ReadAsStringAsync()).ShouldBeEmpty("the token goes back only as an HttpOnly cookie, never into a body a page could read"); + + var cookie = SessionCookieOf(exchange).ShouldNotBeNull("the exchange is what moves the browser onto the cookie"); + + CookieValue(cookie).ShouldBe(bearer); + MaxAgeOf(cookie).ShouldBeLessThanOrEqualTo(TimeSpan.FromHours(24), "the cookie dies with the token it carries, not later"); + (await SendAsync(HttpMethod.Get, "/api/auth/session", cookie: CookieValue(cookie))).StatusCode.ShouldBe(HttpStatusCode.NoContent, "and the browser is now signed in by the cookie alone"); + } + + [Fact] + public async Task A_dead_token_cannot_be_exchanged_for_a_cookie() + { + var account = await AccountAsync(); + + var exchange = await SendAsync(HttpMethod.Post, "/api/auth/session", bearer: TestToken.Mint(account.Id, Guid.NewGuid())); + + exchange.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + SessionCookieOf(exchange).ShouldBeNull(); + } + + [Fact] + public async Task The_dev_spa_origin_may_send_the_header_and_receive_the_cookie_cross_origin() + { + // VITE_API_URL=http://localhost:5099 from the Vite dev server on :5180: a credentialed cross-origin call. The + // preflight must grant the CSRF header, and the response must allow credentials, or the browser drops it. + const string origin = "http://localhost:5180"; + var account = await AccountAsync(); + + var preflight = new HttpRequestMessage(HttpMethod.Options, "/api/auth/sign-in"); + preflight.Headers.Add("Origin", origin); + preflight.Headers.Add("Access-Control-Request-Method", "POST"); + preflight.Headers.Add("Access-Control-Request-Headers", $"content-type,{SessionCookie.CsrfHeader.ToLowerInvariant()},x-team-id"); + + var granted = await _tenants.Client().SendAsync(preflight); + + granted.StatusCode.ShouldBe(HttpStatusCode.NoContent, await TenantHarness.DescribeAsync(granted)); + HeaderOf(granted, "Access-Control-Allow-Origin").ShouldBe(origin); + HeaderOf(granted, "Access-Control-Allow-Credentials").ShouldBe("true"); + HeaderOf(granted, "Access-Control-Allow-Headers")!.ToLowerInvariant().ShouldContain(SessionCookie.CsrfHeader.ToLowerInvariant()); + + var signIn = new HttpRequestMessage(HttpMethod.Post, "/api/auth/sign-in") { Content = JsonContent.Create(new { name = account.Email, password = Password }) }; + signIn.Headers.Add("Origin", origin); + signIn.Headers.Add(SessionCookie.CsrfHeader, "1"); + + var answered = await _tenants.Client().SendAsync(signIn); + + answered.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(answered)); + HeaderOf(answered, "Access-Control-Allow-Origin").ShouldBe(origin); + HeaderOf(answered, "Access-Control-Allow-Credentials").ShouldBe("true", "without it the browser refuses the credentialed response — and the cookie with it"); + SessionCookieOf(answered).ShouldNotBeNull(); + } + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + private async Task<(Guid Id, string Email)> AccountAsync() + { + var email = $"cookie-{Guid.NewGuid():N}@session.test"; + + return (await _tenants.AccountAsync(email: email, password: Password), email); + } + + private async Task SignInResponseAsync(string email) + { + var response = await _tenants.Client().PostAsJsonAsync("/api/auth/sign-in", new { name = email, password = Password }); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(response)); + + return response; + } + + /// + /// The request that mints a session at for the account at . The + /// invitation goes through the product: another account opens a workspace and invites that address, which any + /// account may do — the step a script's author takes before planting the script. + /// + private async Task<(string Path, object Body)> SessionMintingRequestAsync(string endpoint, string email) => endpoint switch + { + "sign-in" => ("/api/auth/sign-in", new { name = email, password = Password }), + "change-password" => ("/api/auth/change-password", new { currentPassword = Password, newPassword = "a-brand-new-passphrase" }), + "accept" => ($"/api/invitations/{await InvitationFromAnotherAccountAsync(email)}/accept", new { }), + _ => throw new ArgumentOutOfRangeException(nameof(endpoint), endpoint, null) + }; + + private async Task InvitationFromAnotherAccountAsync(string email) + { + var inviter = await _tenants.AccountAsync(); + + await GrantEveryAccountsPermissionsAsync(inviter); + + var workspace = await _tenants.SendAsync(HttpMethod.Post, "/api/teams", inviter, null, new { name = $"Workspace {Guid.NewGuid():N}"[..20] }); + workspace.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(workspace)); + var teamId = JsonDocument.Parse(await workspace.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetGuid(); + + var invitation = await _tenants.SendAsync(HttpMethod.Post, "/api/teams/invitations", inviter, teamId, new { email, role = "Member" }); + invitation.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(invitation)); + var inviteUrl = JsonDocument.Parse(await invitation.Content.ReadAsStringAsync()).RootElement.GetProperty("inviteUrl").GetString()!; + + return inviteUrl[(inviteUrl.LastIndexOf('/') + 1)..]; + } + + /// What every account holds from the moment it exists — the right to open a workspace — which an account seeded straight to the row does not get by itself. + private async Task GrantEveryAccountsPermissionsAsync(Guid userId) => await _tenants.DbAsync(async db => + { + foreach (var permissionId in await db.Permission.Where(p => Permissions.GrantedToEveryAccount.Contains(p.Name)).Select(p => p.Id).ToListAsync()) + db.UserPermission.Add(new UserPermission { Id = Guid.NewGuid(), UserId = userId, PermissionId = permissionId, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + + return await db.SaveChangesAsync(); + }); + + /// Signs in as a browser does and returns the session cookie's value. + private async Task SignInAsync(string email) => CookieValue(SessionCookieOf(await SignInResponseAsync(email)).ShouldNotBeNull()); + + private async Task SendAsync(HttpMethod method, string path, string? cookie = null, string? bearer = null, bool csrf = false, object? body = null) + { + var request = new HttpRequestMessage(method, path); + + if (body != null) request.Content = JsonContent.Create(body); + if (cookie != null) request.Headers.Add("Cookie", $"{SessionCookie.Name}={cookie}"); + if (bearer != null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + if (csrf) request.Headers.Add(SessionCookie.CsrfHeader, "1"); + + return await _tenants.Client().SendAsync(request); + } + + private static async Task TokenInBodyAsync(HttpResponseMessage response) => + JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.GetProperty("token").GetString()!; + + private static IEnumerable SetCookies(HttpResponseMessage response) => + response.Headers.TryGetValues("Set-Cookie", out var values) ? values : Array.Empty(); + + private static string? SessionCookieOf(HttpResponseMessage response) => + SetCookies(response).SingleOrDefault(c => c.StartsWith($"{SessionCookie.Name}=", StringComparison.Ordinal)); + + private static string CookieValue(string setCookie) => setCookie.Split(';')[0][(SessionCookie.Name.Length + 1)..]; + + private static TimeSpan MaxAgeOf(string setCookie) => + TimeSpan.FromSeconds(long.Parse(setCookie.Split(';').Select(a => a.Trim()).Single(a => a.StartsWith("max-age=", StringComparison.OrdinalIgnoreCase))["max-age=".Length..])); + + private static string? HeaderOf(HttpResponseMessage response, string name) => + response.Headers.TryGetValues(name, out var values) ? string.Join(",", values) : null; + + /// Whether a Set-Cookie header carries this attribute — compared whole, so "secure" never matches inside another one. + private static bool HasAttribute(string setCookie, string attribute) => + setCookie.Split(';').Skip(1).Any(a => string.Equals(a.Trim(), attribute, StringComparison.OrdinalIgnoreCase)); +} diff --git a/backend/tests/CodeSpace.E2ETests/Storage/ArtifactDownloadEndpointE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Storage/ArtifactDownloadEndpointE2ETests.cs index 2ee9989a1..e55a12466 100644 --- a/backend/tests/CodeSpace.E2ETests/Storage/ArtifactDownloadEndpointE2ETests.cs +++ b/backend/tests/CodeSpace.E2ETests/Storage/ArtifactDownloadEndpointE2ETests.cs @@ -3,6 +3,7 @@ using System.Text.Json; using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Auth; using CodeSpace.E2ETests.Infrastructure; using CodeSpace.Core.Services.Workflows.Artifacts; using CodeSpace.Core.Services.Workflows.Artifacts.Exceptions; @@ -33,14 +34,16 @@ public sealed class ArtifactDownloadEndpointE2ETests : IClassFixture SendAsync(RoutedTeam world, Guid artifactId, bool authenticated = true) + private async Task SendAsync(RoutedTeam world, Guid artifactId, bool authenticated = true, bool viaSessionCookie = false) { var request = new HttpRequestMessage(HttpMethod.Get, $"/api/artifacts/{artifactId}"); - if (authenticated) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TestToken.Mint(world.UserId, TestToken.SeedStamp)); + var token = TestToken.Mint(world.UserId, TestToken.SeedStamp); + if (authenticated && viaSessionCookie) request.Headers.Add("Cookie", $"{SessionCookie.Name}={token}"); + else if (authenticated) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Headers.Add("X-Team-Id", world.TeamId.ToString()); return await _factory.CreateClient().SendAsync(request); diff --git a/backend/tests/CodeSpace.E2ETests/Tasks/RunRecordsEndpointE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Tasks/RunRecordsEndpointE2ETests.cs index c8ce1988e..561fd95c9 100644 --- a/backend/tests/CodeSpace.E2ETests/Tasks/RunRecordsEndpointE2ETests.cs +++ b/backend/tests/CodeSpace.E2ETests/Tasks/RunRecordsEndpointE2ETests.cs @@ -142,8 +142,10 @@ public async Task Read_record_payload_exposes_exact_identity_and_lossless_bounde Encoding.UTF8.GetString(output.ToArray()).ShouldBe(expected); } - [Fact] - public async Task Get_records_stream_tails_the_ledger_as_server_sent_events_and_ends_at_the_terminal_record() + [Theory] + [InlineData(false)] // an API client's Authorization header + [InlineData(true)] // the browser's HttpOnly session cookie — the SPA's tail sends no Authorization header at all + public async Task Get_records_stream_tails_the_ledger_as_server_sent_events_and_ends_at_the_terminal_record(bool viaSessionCookie) { var (userId, teamId) = await SeedTeamMembershipAsync(); var runId = await SeedRunWithRecordsAsync(teamId); // ends with run.failed → the terminal record closes the stream itself @@ -151,7 +153,8 @@ public async Task Get_records_stream_tails_the_ledger_as_server_sent_events_and_ using var client = _factory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(30); // safety net — a correct tail closes the stream at the terminal record var request = new HttpRequestMessage(HttpMethod.Get, $"/api/workflows/runs/{runId}/records/stream?after=0"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken(userId)); + if (viaSessionCookie) request.Headers.Add("Cookie", $"{SessionCookie.Name}={MintToken(userId)}"); + else request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken(userId)); request.Headers.Add("X-Team-Id", teamId.ToString()); var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); diff --git a/backend/tests/CodeSpace.IntegrationTests/Authorization/SessionCookieAuthenticationTests.cs b/backend/tests/CodeSpace.IntegrationTests/Authorization/SessionCookieAuthenticationTests.cs new file mode 100644 index 000000000..fb6ecfcc5 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Authorization/SessionCookieAuthenticationTests.cs @@ -0,0 +1,218 @@ +using System.Security.Claims; +using Autofac; +using CodeSpace.Api.Extensions; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Auth; +using CodeSpace.Core.Settings.Authentication; +using CodeSpace.IntegrationTests.Infrastructure; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Authorization; + +/// +/// The bearer handler as configures it, reading the session cookie, against the +/// real session check in Postgres. Below the HTTP pipeline on purpose: this is the component every request passes +/// through, and each case isolates one rule of it — where the token comes from, which writes the cookie may not carry, +/// and that ending a session ends the cookie's too. The wire itself is SessionCookieE2ETests. +/// +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class SessionCookieAuthenticationTests +{ + private readonly PostgresFixture _fixture; + + public SessionCookieAuthenticationTests(PostgresFixture fixture) { _fixture = fixture; } + + [Fact] + public async Task A_cookie_alone_authenticates_a_live_account() + { + var account = await SeedAccountAsync(); + + var result = await AuthenticateAsync(HttpMethods.Get, cookie: account.Token); + + result.Succeeded.ShouldBeTrue(result.Failure?.Message); + SubjectOf(result).ShouldBe(account.Id); + } + + [Fact] + public async Task Ending_the_session_ends_the_cookie_too() + { + // The stamp is what a password change, a reset and a deactivation all rotate. A cookie is the same token, + // so it must die at the same write — not when the browser gets round to expiring it. + var account = await SeedAccountAsync(); + + (await AuthenticateAsync(HttpMethods.Get, cookie: account.Token)).Succeeded.ShouldBeTrue("the control: the cookie is a live session before anything changes"); + + await RotateStampAsync(account.Id); + + var result = await AuthenticateAsync(HttpMethods.Get, cookie: account.Token); + + result.Succeeded.ShouldBeFalse("the stamp the cookie's token was minted under is gone"); + } + + [Fact] + public async Task The_header_is_read_first_and_the_cookie_never_rescues_it() + { + // A caller that chose a header gets the header's answer. Falling back to a cookie it happens to carry would let + // a dead header quietly become a different, live identity. + var cookieAccount = await SeedAccountAsync(); + var headerAccount = await SeedAccountAsync(); + + await RotateStampAsync(headerAccount.Id); + + (await AuthenticateAsync(HttpMethods.Get, cookie: cookieAccount.Token)).Succeeded.ShouldBeTrue("the control: the cookie alone is a live session"); + + var result = await AuthenticateAsync(HttpMethods.Get, cookie: cookieAccount.Token, bearer: headerAccount.Token); + + result.Succeeded.ShouldBeFalse("the header's token is dead, and the live cookie beside it must not stand in"); + } + + [Fact] + public async Task A_live_header_decides_who_the_caller_is_whatever_cookie_rides_along() + { + var cookieAccount = await SeedAccountAsync(); + var headerAccount = await SeedAccountAsync(); + + var result = await AuthenticateAsync(HttpMethods.Get, cookie: cookieAccount.Token, bearer: headerAccount.Token); + + result.Succeeded.ShouldBeTrue(result.Failure?.Message); + SubjectOf(result).ShouldBe(headerAccount.Id); + } + + [Theory] + [InlineData("POST")] + [InlineData("PUT")] + [InlineData("PATCH")] + [InlineData("DELETE")] + public async Task A_write_riding_the_cookie_without_the_csrf_header_is_not_authenticated(string method) + { + var account = await SeedAccountAsync(); + + var refused = await AuthenticateAsync(method, cookie: account.Token); + var guarded = await AuthenticateAsync(method, cookie: account.Token, csrfHeader: true); + + refused.Succeeded.ShouldBeFalse($"a {method} another site's form could have sent"); + refused.Failure!.Message.ShouldContain(SessionCookie.CsrfHeader, Case.Sensitive, "the log line must say what was missing"); + guarded.Succeeded.ShouldBeTrue($"the control: the same cookie with the header is a live session — {guarded.Failure?.Message}"); + } + + [Theory] + [InlineData("POST")] + [InlineData("DELETE")] + public async Task A_bearer_write_needs_no_csrf_header(string method) + { + // API clients, the CLI and MCP never sent it and must not have to: a header credential is not something a + // browser attaches on another site's behalf. + var account = await SeedAccountAsync(); + + var result = await AuthenticateAsync(method, bearer: account.Token); + + result.Succeeded.ShouldBeTrue(result.Failure?.Message); + } + + [Theory] + [InlineData(true, false, false, true)] // the browser on its cookie: an endpoint that mints a session must keep it out of the body + [InlineData(true, true, false, false)] // a header client with a stray cookie + [InlineData(false, true, false, false)] // a plain header client + [InlineData(true, false, true, false)] // a cookie whose session has ended authenticates nobody, so it carried nothing + public async Task Only_a_principal_the_cookie_established_counts_as_carried_by_it(bool sendCookie, bool sendBearer, bool endSession, bool expected) + { + // The handler as configured decides who the caller is; SessionCookie.Authenticated reads that decision, as the + // session-minting endpoints do after the middleware ran. + var account = await SeedAccountAsync(); + + if (endSession) await RotateStampAsync(account.Id); + + var context = await AuthenticatedContextAsync(HttpMethods.Post, cookie: sendCookie ? account.Token : null, bearer: sendBearer ? account.Token : null, csrfHeader: true); + + SessionCookie.Authenticated(context).ShouldBe(expected); + } + + private async Task AuthenticateAsync(string method, string? cookie = null, string? bearer = null, bool csrfHeader = false) + { + using var fixtureScope = _fixture.BeginScope(); + await using var provider = BuildAuthentication(fixtureScope); + await using var requestScope = provider.CreateAsyncScope(); + + return await RequestFor(requestScope.ServiceProvider, method, cookie, bearer, csrfHeader).AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme); + } + + /// A request after the authentication middleware: is whatever the handler established, as an action sees it. + private async Task AuthenticatedContextAsync(string method, string? cookie, string? bearer, bool csrfHeader) + { + using var fixtureScope = _fixture.BeginScope(); + await using var provider = BuildAuthentication(fixtureScope); + await using var requestScope = provider.CreateAsyncScope(); + + var context = RequestFor(requestScope.ServiceProvider, method, cookie, bearer, csrfHeader); + var result = await context.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme); + + if (result.Succeeded) context.User = result.Principal; + + return context; + } + + private static DefaultHttpContext RequestFor(IServiceProvider services, string method, string? cookie, string? bearer, bool csrfHeader) + { + var context = new DefaultHttpContext { RequestServices = services }; + + context.Request.Method = method; + + if (cookie != null) context.Request.Headers.Cookie = $"{SessionCookie.Name}={cookie}"; + if (bearer != null) context.Request.Headers.Authorization = $"Bearer {bearer}"; + if (csrfHeader) context.Request.Headers[SessionCookie.CsrfHeader] = "1"; + + return context; + } + + /// The production authentication registration, with the session check resolved from the fixture's real container and database. + private static ServiceProvider BuildAuthentication(ILifetimeScope fixtureScope) + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Authentication:Jwt:SymmetricKey"] = fixtureScope.Resolve().Value }).Build(); + var services = new ServiceCollection(); + + services.AddLogging(); + services.AddCustomAuthentication(configuration, new ProductionEnvironment()); + services.AddScoped(_ => fixtureScope.Resolve()); + + return services.BuildServiceProvider(); + } + + private async Task<(Guid Id, string Token)> SeedAccountAsync() + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var user = new User { Id = Guid.NewGuid(), Email = $"cookie-{Guid.NewGuid():N}@session.test", Name = $"cookie-{Guid.NewGuid():N}", SecurityStamp = Guid.NewGuid() }; + + db.User.Add(user); + await db.SaveChangesAsync(); + + return (user.Id, scope.Resolve().Issue(user).Token); + } + + private async Task RotateStampAsync(Guid userId) + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().User.Where(u => u.Id == userId).ExecuteUpdateAsync(u => u.SetProperty(x => x.SecurityStamp, Guid.NewGuid())); + } + + private static Guid SubjectOf(AuthenticateResult result) => Guid.Parse(result.Principal!.FindFirstValue(ClaimTypes.NameIdentifier)!); + + private sealed class ProductionEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = Environments.Production; + public string ApplicationName { get; set; } = "test"; + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + public IFileProvider ContentRootFileProvider { get; set; } = null!; + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Auth/BearerTokenLifetimeTests.cs b/backend/tests/CodeSpace.UnitTests/Auth/BearerTokenLifetimeTests.cs index dcad487b8..1f16f0890 100644 --- a/backend/tests/CodeSpace.UnitTests/Auth/BearerTokenLifetimeTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Auth/BearerTokenLifetimeTests.cs @@ -41,6 +41,21 @@ public void Never_accepts_an_absent_principal() BearerTokenLifetime.Accepts(null, Expiry.AddHours(-1)).ShouldBeFalse(); } + /// A browser session cookie is set to die with the token it carries, so the expiry is read from the same claim the handler checks. + [Fact] + public void ExpiresAt_reads_the_tokens_exp() + { + BearerTokenLifetime.ExpiresAt(PrincipalWithExpiry(Expiry.ToUnixTimeSeconds().ToString())).ShouldBe(Expiry); + } + + [Theory] + [InlineData(null)] + [InlineData("tomorrow")] + public void ExpiresAt_is_null_without_a_readable_exp(string? exp) + { + BearerTokenLifetime.ExpiresAt(PrincipalWithExpiry(exp)).ShouldBeNull(); + } + /// The handler's skew. Moving it moves the moment an open tail ends; pinned so that is a visible decision. [Fact] public void ClockSkew_is_five_minutes() diff --git a/backend/tests/CodeSpace.UnitTests/Auth/SessionCookieTests.cs b/backend/tests/CodeSpace.UnitTests/Auth/SessionCookieTests.cs new file mode 100644 index 000000000..7321bb1b4 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Auth/SessionCookieTests.cs @@ -0,0 +1,191 @@ +using System.Security.Claims; +using CodeSpace.Core.Services.Auth; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Shouldly; + +namespace CodeSpace.UnitTests.Auth; + +/// +/// The browser's session cookie: the attributes it is written with, when the bearer handler reads it, and which writes +/// it may not carry on its own. Every rule here is a defence a browser enforces only if the server asked for it, so each +/// one is asserted on the header the browser actually receives. +/// +[Trait("Category", "Unit")] +public class SessionCookieTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 12, 9, 0, 0, TimeSpan.Zero); + + /// The SPA, the CORS preflight and an operator's proxy rules all name these. A rename is a wire change, so it has to be a visible one. + [Fact] + public void The_wire_names_are_pinned() + { + SessionCookie.Name.ShouldBe("codespace_session"); + SessionCookie.Path.ShouldBe("/api"); + SessionCookie.CsrfHeader.ShouldBe("X-CodeSpace-Csrf"); + } + + [Theory] + [InlineData("https", true)] // the browser keeps it off plain HTTP + [InlineData("http", false)] // a browser drops a Secure cookie set over HTTP — Safari even on localhost — so a plain-HTTP deployment could never sign in + public void Issuing_writes_an_http_only_strict_cookie_scoped_to_the_api_that_is_secure_exactly_over_https(string scheme, bool secure) + { + var context = ContextAt(scheme); + + SessionCookie.Issue(context, "the-jwt", Now.AddHours(24)); + + var cookie = SetCookieOf(context); + + cookie.ShouldStartWith($"{SessionCookie.Name}=the-jwt;"); + HasAttribute(cookie, "httponly").ShouldBeTrue("no script on the page may read the session"); + HasAttribute(cookie, "samesite=strict").ShouldBeTrue("no request another site starts may carry it"); + HasAttribute(cookie, "path=/api").ShouldBeTrue("only the API needs it"); + HasAttribute(cookie, "secure").ShouldBe(secure, $"over {scheme} — Set-Cookie: {cookie}"); + } + + [Fact] + public void The_cookie_lives_exactly_as_long_as_the_token_it_carries() + { + // Relative, not an absolute Expires: a browser with a wrong clock would otherwise drop it early or keep it late. + var context = ContextAt("https"); + + SessionCookie.Issue(context, "the-jwt", Now.AddHours(23).AddSeconds(17)); + + HasAttribute(SetCookieOf(context), $"max-age={(23 * 3600) + 17}").ShouldBeTrue(SetCookieOf(context)); + } + + [Theory] + [InlineData("https", true)] + [InlineData("http", false)] + public void Clearing_expires_the_cookie_with_the_attributes_it_was_set_with(string scheme, bool secure) + { + // A deletion at a different path or with a different Secure flag is a second cookie, and the browser keeps the first. + var context = ContextAt(scheme); + + SessionCookie.Clear(context); + + var cookie = SetCookieOf(context); + + cookie.ShouldStartWith($"{SessionCookie.Name}=;"); + HasAttribute(cookie, "expires=Thu, 01 Jan 1970 00:00:00 GMT").ShouldBeTrue(cookie); + HasAttribute(cookie, "path=/api").ShouldBeTrue(cookie); + HasAttribute(cookie, "samesite=strict").ShouldBeTrue(cookie); + HasAttribute(cookie, "httponly").ShouldBeTrue(cookie); + HasAttribute(cookie, "secure").ShouldBe(secure, cookie); + } + + [Fact] + public void A_request_with_only_the_cookie_authenticates_with_the_cookie() + { + var request = RequestWith(cookie: "the-jwt", authorization: null); + + SessionCookie.ReadToken(request).ShouldBe("the-jwt"); + } + + [Theory] + [InlineData("Bearer header-jwt")] + [InlineData("Bearer ")] // even a malformed header: the caller chose the header, and its failure must not fall back to a credential it did not send + [InlineData("Basic abc")] + public void An_authorization_header_wins_and_the_cookie_is_not_read(string authorization) + { + // API clients, the CLI, MCP and every test send the header. A browser cookie riding along must never change who they are. + var request = RequestWith(cookie: "cookie-jwt", authorization: authorization); + + SessionCookie.ReadToken(request).ShouldBeNull(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void No_cookie_means_no_cookie_token(string? cookie) + { + SessionCookie.ReadToken(RequestWith(cookie, authorization: null)).ShouldBeNull(); + } + + [Theory] + [InlineData("GET")] + [InlineData("HEAD")] + [InlineData("OPTIONS")] + [InlineData("TRACE")] + public void A_safe_method_may_ride_the_cookie_without_the_header(string method) + { + // Downloads and the run-ledger stream are GETs; a GET changes nothing, so a forged one gains nothing. + SessionCookie.IsUnguardedWrite(RequestFor(method, csrfHeader: null)).ShouldBeFalse(); + } + + [Theory] + [InlineData("POST")] + [InlineData("PUT")] + [InlineData("PATCH")] + [InlineData("DELETE")] + [InlineData("PROPFIND")] // anything not known to be safe is a write + public void A_write_without_the_header_is_unguarded(string method) + { + // A form on another site, or a fetch it makes without a preflight, can send the cookie but cannot set a custom header. + SessionCookie.IsUnguardedWrite(RequestFor(method, csrfHeader: null)).ShouldBeTrue(); + SessionCookie.IsUnguardedWrite(RequestFor(method, csrfHeader: " ")).ShouldBeTrue("a blank header is no header"); + } + + [Theory] + [InlineData("POST")] + [InlineData("PUT")] + [InlineData("PATCH")] + [InlineData("DELETE")] + public void A_write_with_the_header_is_guarded(string method) + { + SessionCookie.IsUnguardedWrite(RequestFor(method, csrfHeader: "1")).ShouldBeFalse(); + } + + [Theory] + [InlineData(true, true, null, true)] // the browser on its cookie: the session must not come back in the body + [InlineData(true, true, "Bearer header-jwt", false)] // a header client with a stray cookie: the header carried it + [InlineData(true, false, "Bearer header-jwt", false)] // a plain bearer client + [InlineData(false, true, null, false)] // a dead or unguarded cookie: nobody was authenticated, by the cookie or otherwise + [InlineData(false, false, null, false)] // anonymous + public void Only_a_caller_the_cookie_authenticated_counts_as_carried_by_it(bool authenticated, bool hasCookie, string? authorization, bool expected) + { + var context = new DefaultHttpContext { User = authenticated ? new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())], "Bearer")) : new ClaimsPrincipal(new ClaimsIdentity()) }; + + if (hasCookie) context.Request.Headers.Cookie = $"{SessionCookie.Name}=cookie-jwt"; + if (authorization != null) context.Request.Headers.Authorization = authorization; + + SessionCookie.Authenticated(context).ShouldBe(expected); + } + + private static DefaultHttpContext ContextAt(string scheme) + { + var context = new DefaultHttpContext { RequestServices = new ServiceCollection().AddSingleton(new FakeTimeProvider(Now)).BuildServiceProvider() }; + + context.Request.Scheme = scheme; + + return context; + } + + private static HttpRequest RequestWith(string? cookie, string? authorization) + { + var context = new DefaultHttpContext(); + + if (cookie != null) context.Request.Headers.Cookie = $"{SessionCookie.Name}={cookie}"; + if (authorization != null) context.Request.Headers.Authorization = authorization; + + return context.Request; + } + + private static HttpRequest RequestFor(string method, string? csrfHeader) + { + var context = new DefaultHttpContext(); + + context.Request.Method = method; + + if (csrfHeader != null) context.Request.Headers[SessionCookie.CsrfHeader] = csrfHeader; + + return context.Request; + } + + private static string SetCookieOf(HttpContext context) => context.Response.Headers.SetCookie.ToString(); + + /// Whether a Set-Cookie header carries this attribute — compared whole, so "secure" never matches inside another one. + private static bool HasAttribute(string setCookie, string attribute) => + setCookie.Split(';').Skip(1).Any(a => string.Equals(a.Trim(), attribute, StringComparison.OrdinalIgnoreCase)); +} diff --git a/frontend/.env.example b/frontend/.env.example index 1bbff8bff..1bd3bb6c7 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -25,5 +25,11 @@ # 2. The backend's `OAuth:CallbackUrl` must point at THIS SPA's origin too # (e.g. http://localhost:5180/api/credentials/oauth/callback), because # the OAuth popup messages back into the SPA window. +# 3. For the HttpOnly session cookie, the API must be on the SAME SITE as +# the SPA (same host name; ports may differ): http://localhost:5180 → +# http://localhost:5099 works, http://127.0.0.1:5180 → localhost does +# not. On another site the browser withholds the SameSite=Strict cookie +# and the SPA keeps its bearer token in localStorage instead — it still +# works, without the protection the cookie adds. # VITE_API_URL= diff --git a/frontend/nginx.conf b/frontend/nginx.conf index fad8e988c..c17eeb827 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -16,7 +16,8 @@ server { add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always; - # Browser-side defenses for a page that renders untrusted markdown and keeps its session token in localStorage. + # Browser-side defenses for a page that renders untrusted markdown inside a signed-in session (an HttpOnly cookie, + # or — where the API cannot confirm one — a bearer token in localStorage). # They sit here beside the CORS headers because nginx inherits add_header from an outer level only when a level # declares none of its own. # script-src 'self' no inline script, no eval — the runtime env is /env.js for exactly this reason. diff --git a/frontend/src/_imported/ai-code-space/sidebar.tsx b/frontend/src/_imported/ai-code-space/sidebar.tsx index 12335e87a..b35a19c8f 100644 --- a/frontend/src/_imported/ai-code-space/sidebar.tsx +++ b/frontend/src/_imported/ai-code-space/sidebar.tsx @@ -2,7 +2,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { clearAuthState } from "@/api/auth"; +import { signOut } from "@/api/session"; import type { MeTeam } from "@/api/types"; import { CreateWorkspaceDialog } from "@/components/settings/CreateWorkspaceDialog"; import { teamToUrlSlug, useActiveTeam } from "@/hooks/use-me"; @@ -227,8 +227,9 @@ export function Sidebar() {