Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions backend/src/CodeSpace.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<IActionResult> 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<IActionResult> 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.

/// <summary>"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.</summary>
[HttpGet("session")]
public IActionResult ProbeSession() => NoContent();

/// <summary>
/// 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.
/// </summary>
[HttpPost("session")]
public async Task<IActionResult> EstablishSession()
{
var token = await HttpContext.GetTokenAsync(JwtBearerDefaults.AuthenticationScheme, "access_token").ConfigureAwait(false);

SessionCookie.Issue(HttpContext, token!, BearerTokenLifetime.ExpiresAt(User)!.Value);

return NoContent();
}

/// <summary>Signs this browser out by clearing its cookie. Other sessions stay live: ending every session is what changing the password does.</summary>
[HttpDelete("session")]
public IActionResult EndSession()
{
SessionCookie.Clear(HttpContext);

return NoContent();
}

/// <summary>
Expand Down
12 changes: 10 additions & 2 deletions backend/src/CodeSpace.Api/Controllers/InvitationsController.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CodeSpace.Core.Services.Auth;
using CodeSpace.Messages.Commands.Invitations;
using CodeSpace.Messages.Queries.Invitations;
using MediatR;
Expand Down Expand Up @@ -35,12 +36,19 @@ public async Task<IActionResult> Preview([FromRoute] string token, CancellationT
return Ok(result);
}

/// <summary>Spends the invitation and answers with a session, so the invitee lands signed in.</summary>
/// <summary>
/// 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.
/// </summary>
[HttpPost("{token}/accept")]
public async Task<IActionResult> 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);
}
}
29 changes: 27 additions & 2 deletions backend/src/CodeSpace.Api/Extensions/AuthenticationExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand All @@ -50,6 +55,26 @@ public static void AddCustomAuthentication(this IServiceCollection services, ICo
});
}

/// <summary>
/// A browser carries its session in <see cref="SessionCookie"/>, 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 <see cref="SessionCookie.CsrfHeader"/>;
/// without it the cookie is not believed, and the request is as anonymous as one another site could have sent.
/// </summary>
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);
Expand Down
4 changes: 2 additions & 2 deletions backend/src/CodeSpace.Api/Extensions/CorsPolicyExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ public static class BearerTokenLifetime
/// including its <c>exp</c> plus <see cref="ClockSkew"/>. False for a principal with no readable <c>exp</c>, which no
/// token the handler lets through lacks.
/// </summary>
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) =>
/// <summary>The principal's token <c>exp</c>, or null when it carries no readable one. Also how long a browser session cookie holding that token may live.</summary>
public static DateTimeOffset? ExpiresAt(ClaimsPrincipal? principal) =>
long.TryParse(principal?.FindFirst(JwtRegisteredClaimNames.Exp)?.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) ? DateTimeOffset.FromUnixTimeSeconds(seconds) : null;
}
81 changes: 81 additions & 0 deletions backend/src/CodeSpace.Core/Services/Auth/SessionCookie.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using HeaderNames = Microsoft.Net.Http.Headers.HeaderNames;

namespace CodeSpace.Core.Services.Auth;

/// <summary>
/// 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 <c>localStorage</c> is one injected script away from leaving the browser for a day; a
/// token the page never sees cannot be exfiltrated by the page.
///
/// <para>The bearer handler reads it only when a request carries no <c>Authorization</c> 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.</para>
///
/// <para>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: <c>SameSite=Strict</c>, so the browser does not attach it to a request
/// another site initiates; <see cref="CsrfHeader"/>, 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.</para>
/// </summary>
public static class SessionCookie
{
/// <summary>Pinned by unit test. Not <c>__Host-</c>-prefixed: that prefix demands <c>Secure</c> and <c>Path=/</c>, and this cookie has neither on a plain-HTTP deployment and wants the narrower path.</summary>
public const string Name = "codespace_session";

/// <summary>Only API requests need the session. The SPA's own pages never receive it, and neither does <c>/hangfire</c>, which keeps its own write surface off this cookie.</summary>
public const string Path = "/api";

/// <summary>
/// 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.
/// </summary>
public const string CsrfHeader = "X-CodeSpace-Csrf";

/// <summary>Hands the browser the session <paramref name="token"/>, to expire with the token itself.</summary>
public static void Issue(HttpContext context, string token, DateTimeOffset expiresAt) => context.Response.Cookies.Append(Name, token, Options(context, expiresAt - ClockOf(context).GetUtcNow()));

/// <summary>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.</summary>
public static void Clear(HttpContext context) => context.Response.Cookies.Delete(Name, Options(context, maxAge: null));

/// <summary>
/// The token the bearer handler should validate from the cookie: null when the request carries an <c>Authorization</c>
/// header (the header is the caller's choice and is read first, even when it fails) or no session cookie at all.
/// </summary>
public static string? ReadToken(HttpRequest request) => request.Headers.ContainsKey(HeaderNames.Authorization) ? null : NullIfEmpty(request.Cookies[Name]);

/// <summary>
/// True when the caller was authenticated by the cookie: signed in, with no <c>Authorization</c> 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
/// <see cref="CsrfHeader"/> 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.
/// </summary>
public static bool Authenticated(HttpContext context) => context.User.Identity?.IsAuthenticated == true && ReadToken(context.Request) != null;

/// <summary>True for a request that would change something on the strength of the cookie alone: any method not known to be safe, without <see cref="CsrfHeader"/>.</summary>
public static bool IsUnguardedWrite(HttpRequest request) => !IsSafeMethod(request.Method) && string.IsNullOrWhiteSpace(request.Headers[CsrfHeader]);

/// <summary>
/// <c>Secure</c> 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).
/// </summary>
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>() ?? TimeProvider.System;

private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using CodeSpace.Messages.Authorization;
using CodeSpace.Messages.Dtos.Users;
using CodeSpace.Messages.Mediation;
Expand All @@ -24,9 +25,11 @@ public sealed record ChangePasswordResponse
/// <summary>
/// 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 <see cref="SignInResponse.Token"/>: that caller gets the new session as the replacement cookie alone.
/// </summary>
public required string Token { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public required string? Token { get; init; }

public required DateTimeOffset ExpiresAt { get; init; }
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using CodeSpace.Messages.Authorization;
using CodeSpace.Messages.Dtos.Users;
using CodeSpace.Messages.Mediation;
Expand All @@ -23,7 +24,13 @@ public sealed record SignInCommand : ICommand<SignInResponse>, IBypassPasswordRo

public sealed record SignInResponse
{
public required string Token { get; init; }
/// <summary>
/// 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.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public required string? Token { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public required MeResponse User { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}
Expand Down
Loading
Loading