From f8d53901089b2148065a06d0725781ab8c6d4b55 Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Mon, 12 Oct 2026 17:14:40 +0800 Subject: [PATCH] Keep the session token out of page scripts The SPA kept its 24-hour JWT in localStorage, so any script that ran in the page could read it and send it anywhere. The allowlist sanitizer and CSP narrow how such a script gets in; this removes what it could take. Sign-in, invitation accept and password change now set the token in an HttpOnly, SameSite=Strict cookie scoped to /api, Secure exactly when the request is HTTPS and living as long as the token. The bearer handler reads it only when no Authorization header is present, so API clients, the CLI, MCP and tests are untouched. A write that rides the cookie must carry X-CodeSpace-Csrf, which no form or preflight-free request from another site can set. The stamp check runs whichever way the token arrived, so every way a session ends still ends the cookie's. A script in the page can send the cookie and that header too, so a response to a request the cookie carried never puts the minted token in its body. Otherwise accepting an invitation the script's author sent would mint the victim a fresh, portable token. Bearer callers still get it. The SPA settles a session inside the mutation that received it and keeps the response without the token, and drops those mutations with the page: react-query keeps results and variables (the passwords) reachable from the DOM for minutes. /api/auth/session answers "am I signed in" (GET), trades a stored token for the cookie without extending it (POST), and signs the browser out (DELETE). The SPA moves onto the cookie only after the API confirms the browser sends it back; until then, including against an API on another site or pods from before this change, it keeps the bearer token it already had. Once on the cookie, a 401 is checked against the API before it signs anyone out, because a pre-cookie pod reads only the header. That check can reach such a pod too, so the API must finish rolling out before the SPA does; rolling the API back later signs cookie users out once. --- .../Controllers/AuthController.cs | 51 ++- .../Controllers/InvitationsController.cs | 12 +- .../Extensions/AuthenticationExtension.cs | 29 +- .../Extensions/CorsPolicyExtension.cs | 4 +- .../Services/Auth/BearerTokenLifetime.cs | 5 +- .../Services/Auth/SessionCookie.cs | 81 ++++ .../Commands/Auth/ChangePasswordCommand.cs | 7 +- .../Commands/Auth/SignInCommand.cs | 9 +- .../Auth/InvitationEndpointE2ETests.cs | 7 + .../Auth/SessionCookieE2ETests.cs | 357 ++++++++++++++++++ .../ArtifactDownloadEndpointE2ETests.cs | 15 +- .../Tasks/RunRecordsEndpointE2ETests.cs | 9 +- .../SessionCookieAuthenticationTests.cs | 218 +++++++++++ .../Auth/BearerTokenLifetimeTests.cs | 15 + .../Auth/SessionCookieTests.cs | 191 ++++++++++ frontend/.env.example | 6 + frontend/nginx.conf | 3 +- .../src/_imported/ai-code-space/sidebar.tsx | 7 +- frontend/src/api/artifacts.ts | 4 +- frontend/src/api/auth.ts | 34 +- frontend/src/api/client.ts | 11 +- frontend/src/api/request.test.ts | 57 ++- frontend/src/api/request.ts | 30 +- frontend/src/api/rolling-deploy.test.ts | 104 +++++ frontend/src/api/run-stream.test.ts | 41 ++ frontend/src/api/run-stream.ts | 15 +- frontend/src/api/session.test.ts | 272 +++++++++++++ frontend/src/api/session.ts | 150 ++++++++ frontend/src/components/sidebar.test.tsx | 27 ++ frontend/src/hooks/use-change-password.ts | 18 +- frontend/src/hooks/use-sign-in.ts | 12 +- frontend/src/main.tsx | 6 + frontend/src/routes/_app.tsx | 2 +- frontend/src/routes/change-password.tsx | 6 +- frontend/src/routes/invite.$token.test.tsx | 29 +- frontend/src/routes/invite.$token.tsx | 8 +- .../src/routes/session-mutations.test.tsx | 115 ++++++ frontend/src/routes/signin.test.tsx | 91 +++++ frontend/src/test/route-harness.tsx | 7 +- 39 files changed, 1965 insertions(+), 100 deletions(-) create mode 100644 backend/src/CodeSpace.Core/Services/Auth/SessionCookie.cs create mode 100644 backend/tests/CodeSpace.E2ETests/Auth/SessionCookieE2ETests.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Authorization/SessionCookieAuthenticationTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Auth/SessionCookieTests.cs create mode 100644 frontend/src/api/rolling-deploy.test.ts create mode 100644 frontend/src/api/run-stream.test.ts create mode 100644 frontend/src/api/session.test.ts create mode 100644 frontend/src/api/session.ts create mode 100644 frontend/src/routes/session-mutations.test.tsx create mode 100644 frontend/src/routes/signin.test.tsx 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() {