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
8 changes: 5 additions & 3 deletions backend/src/CodeSpace.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,17 @@ public class AuthController : ControllerBase

// 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.
// the body to a request the cookie carried: that caller may be a script in the page, and it could read a body. And
// the cookie only to a request that carries the SPA's CSRF header: a client that predates the cookie could never
// clear it, so it keeps the body's session alone (SessionCookie.IssueIfRequested).

[HttpPost("sign-in")]
[AllowAnonymous]
public async Task<IActionResult> SignIn([FromBody] SignInCommand command, CancellationToken cancellationToken)
{
var result = await _mediator.Send(command, cancellationToken).ConfigureAwait(false);

SessionCookie.Issue(HttpContext, result.Token!, result.ExpiresAt);
SessionCookie.IssueIfRequested(HttpContext, result.Token!, result.ExpiresAt);

return Ok(SessionCookie.Authenticated(HttpContext) ? result with { Token = null } : result);
}
Expand All @@ -48,7 +50,7 @@ public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordCommand
{
var result = await _mediator.Send(command, cancellationToken).ConfigureAwait(false);

SessionCookie.Issue(HttpContext, result.Token!, result.ExpiresAt);
SessionCookie.IssueIfRequested(HttpContext, result.Token!, result.ExpiresAt);

return Ok(SessionCookie.Authenticated(HttpContext) ? result with { Token = null } : result);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,17 @@ public async Task<IActionResult> Preview([FromRoute] string token, CancellationT

/// <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.
/// cookie when the request carries the SPA's CSRF header (a client that predates the cookie could never clear one), 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);

SessionCookie.Issue(HttpContext, result.Token!, result.ExpiresAt);
SessionCookie.IssueIfRequested(HttpContext, result.Token!, result.ExpiresAt);

return Ok(SessionCookie.Authenticated(HttpContext) ? result with { Token = null } : result);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ public sealed class ListIssuesQueryHandler : IRequestHandler<ListIssuesQuery, IR
public async Task<IReadOnlyList<RemoteIssue>> Handle(ListIssuesQuery request, CancellationToken cancellationToken)
{
// Same clamp discipline as ListPullRequestsQueryHandler — handler is dispatch-only per Rule 16. The ceilings live on
// the query record itself; the handler holds the caller to them.
var page = Math.Clamp(request.Page, 1, ListIssuesQuery.MaxPage);
// the query record itself; the handler holds the caller to them. A page past the last one the list allows is empty,
// not the last page again.
if (request.Page > ListIssuesQuery.MaxPage) return [];

var page = Math.Max(request.Page, 1);
var perPage = Math.Clamp(request.PerPage, 1, ListIssuesQuery.MaxPerPage);
return await _service.ListAsync(request.RepositoryId, _currentTeam.Id!.Value, request.State, page, perPage, cancellationToken).ConfigureAwait(false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ public sealed class ListPullRequestsQueryHandler : IRequestHandler<ListPullReque

public async Task<IReadOnlyList<RemotePullRequest>> Handle(ListPullRequestsQuery request, CancellationToken cancellationToken)
{
// The ceilings live on the query record itself; the handler holds the caller to them.
var page = Math.Clamp(request.Page, 1, ListPullRequestsQuery.MaxPage);
// The ceilings live on the query record itself; the handler holds the caller to them. A page past the last one the
// list allows is empty: clamping it to the last page would hand back that page's rows again, as the answer for a page
// further on, to a caller who pages until one comes back empty.
if (request.Page > ListPullRequestsQuery.MaxPage) return [];

var page = Math.Max(request.Page, 1);
var perPage = Math.Clamp(request.PerPage, 1, ListPullRequestsQuery.MaxPerPage);
return await _service.ListAsync(request.RepositoryId, _currentTeam.Id!.Value, request.State, page, perPage, cancellationToken).ConfigureAwait(false);
}
Expand Down
17 changes: 16 additions & 1 deletion backend/src/CodeSpace.Core/Services/Auth/SessionCookie.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ public static class SessionCookie
/// <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>
/// <see cref="Issue"/> for an endpoint that mints a session for whoever calls it, only when the request carries
/// <see cref="CsrfHeader"/>: the SPA that knows the cookie sends it on every request, and nothing older does. A client
/// that predates the cookie — an earlier SPA bundle, an API client, the CLI — cannot end one: only the API can clear an
/// HttpOnly cookie, and that bundle's sign-out empties its own storage and nothing else. Handed one, it would hold a
/// 24-hour session it can never sign out of. It keeps the session the body carries, as it always did.
/// </summary>
public static void IssueIfRequested(HttpContext context, string token, DateTimeOffset expiresAt)
{
if (CarriesCsrfHeader(context.Request)) Issue(context, token, expiresAt);
}

/// <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));

Expand All @@ -55,7 +67,7 @@ public static class SessionCookie
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]);
public static bool IsUnguardedWrite(HttpRequest request) => !IsSafeMethod(request.Method) && !CarriesCsrfHeader(request);

/// <summary>
/// <c>Secure</c> exactly when the request arrived over HTTPS — the effective scheme, after whatever forwarded-headers
Expand All @@ -73,6 +85,9 @@ public static class SessionCookie
IsEssential = true,
};

/// <summary>A blank header is no header: it proves nothing a form could not have sent, which is what the header is for.</summary>
private static bool CarriesCsrfHeader(HttpRequest request) => !string.IsNullOrWhiteSpace(request.Headers[CsrfHeader]);

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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text.Json;
using CodeSpace.Core.Services.PullRequests;
using CodeSpace.Core.Services.Workflows.Runtime;
using CodeSpace.Messages.Dtos.Providers;
using CodeSpace.Messages.Enums;
using CodeSpace.Messages.Queries.Repositories;
using Microsoft.Extensions.Logging;
Expand All @@ -16,7 +17,8 @@ namespace CodeSpace.Core.Services.Workflows.Nodes.Builtin;
/// (e.g. "review every open PR": list → loop → fetch diff → agent → post review). Read-only; reuses
/// the same catalog capability the Pulls tab uses, so it's provider-agnostic (GitHub + GitLab). Page and page size are
/// held to the Pulls tab's own ceilings (<see cref="ListPullRequestsQuery.MaxPage"/>, <see cref="ListPullRequestsQuery.MaxPerPage"/>):
/// called by an agent, they are the model's numbers.
/// called by an agent, they are the model's numbers. A page size past its ceiling is the ceiling; a page past its is an
/// empty page, with no request to the provider.
/// </summary>
public sealed class GitListPullRequestsNode : INodeRuntime
{
Expand Down Expand Up @@ -59,7 +61,7 @@ public GitListPullRequestsNode(IPullRequestService prService)
"properties": {
"repositoryId": { "type": "string", "format": "uuid", "x-selector": "repository", "description": "The repository. Pick one, or switch to Expression to bind from the trigger (e.g. {{trigger.repositoryId}})." },
"state": { "type": "string", "enum": ["Open","Draft","Merged","Closed"], "description": "Only list requests in this state. Leave empty to list all." },
"page": { "type": "integer", "minimum": 1, "maximum": 1000, "x-control": "stepper", "x-advanced": true, "description": "Page of results (default 1, max 1000)." },
"page": { "type": "integer", "minimum": 1, "maximum": 1000, "x-control": "stepper", "x-advanced": true, "description": "Page of results (default 1, max 1000; a later page is empty)." },
"perPage": { "type": "integer", "minimum": 1, "maximum": 100, "default": 30, "x-control": "stepper", "x-advanced": true, "description": "Results per page (default 30, max 100)." }
},
"required": ["repositoryId"]
Expand Down Expand Up @@ -103,9 +105,11 @@ public async Task<NodeResult> RunAsync(NodeRunContext context, CancellationToken
if (!TryReadState(context, out var state)) return NodeResult.Fail("Input 'state' must be one of Open, Draft, Merged, Closed.");
if (!NodeScopeReader.TryReadTeamId(context, out var teamId)) return NodeResult.Fail("This run has no team context, so a repository can't be resolved.");

var page = Math.Min(ReadPositiveInt(context, "page", DefaultPage), ListPullRequestsQuery.MaxPage);
var page = ReadPositiveInt(context, "page", DefaultPage);
var perPage = Math.Min(ReadPositiveInt(context, "perPage", DefaultPerPage), ListPullRequestsQuery.MaxPerPage);

if (page > ListPullRequestsQuery.MaxPage) return PastTheLastPage(context, repoId, page);

var pullRequests = await context.Observability.TraceExternalCallAsync(
target: $"git.list_prs:{repoId}",
method: "list_pull_requests",
Expand All @@ -117,17 +121,29 @@ public async Task<NodeResult> RunAsync(NodeRunContext context, CancellationToken
},
cancellationToken: cancellationToken).ConfigureAwait(false);

var outputs = new Dictionary<string, JsonElement>
{
["pullRequests"] = JsonSerializer.SerializeToElement(pullRequests, WorkflowJson.Options),
["count"] = JsonSerializer.SerializeToElement(pullRequests.Count)
};

context.Logger.LogInformation("Listed {Count} pull request(s) for repo {RepoId} (state {State})", pullRequests.Count, repoId, state?.ToString() ?? "any");

return NodeResult.Ok(outputs);
return NodeResult.Ok(OutputsOf(pullRequests));
}

/// <summary>
/// A page past the last one the list allows is an empty page. Reading the last page for it instead returned the rows the
/// caller had just seen under a page number that said it was further on, so a loop paging until it got an empty page
/// never got one.
/// </summary>
private static NodeResult PastTheLastPage(NodeRunContext context, Guid repoId, int page)
{
context.Logger.LogInformation("Page {Page} of the pull requests for repo {RepoId} is past the last page the list allows ({MaxPage}); returning an empty page", page, repoId, ListPullRequestsQuery.MaxPage);

return NodeResult.Ok(OutputsOf([]));
}

private static Dictionary<string, JsonElement> OutputsOf(IReadOnlyList<RemotePullRequest> pullRequests) => new()
{
["pullRequests"] = JsonSerializer.SerializeToElement(pullRequests, WorkflowJson.Options),
["count"] = JsonSerializer.SerializeToElement(pullRequests.Count)
};

private static bool TryReadRepositoryId(NodeRunContext context, out Guid repoId)
{
repoId = Guid.Empty;
Expand All @@ -154,12 +170,17 @@ private static bool TryReadState(NodeRunContext context, out PullRequestState? s
return true;
}

/// <summary>Read an optional positive-int input, falling back to <paramref name="fallback"/> when absent or non-positive.</summary>
/// <summary>
/// Read an optional positive-int input, falling back to <paramref name="fallback"/> when absent, not a number, or below one.
/// A number is read by its value, not by how it is written: one an Int32 cannot hold (3000000000) is the very large number
/// it is — <see cref="int.MaxValue"/> — and not a missing one, which would turn "a page far past the last" into page 1;
/// one written with a fraction or an exponent (1001.0, 1.001e3, 1001.5) is read at its whole part.
/// </summary>
private static int ReadPositiveInt(NodeRunContext context, string key, int fallback)
{
if (context.Inputs.TryGetValue(key, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n) && n > 0)
return n;
if (!context.Inputs.TryGetValue(key, out var value) || value.ValueKind != JsonValueKind.Number) return fallback;
if (value.TryGetInt32(out var whole)) return whole > 0 ? whole : fallback;

return fallback;
return value.TryGetDouble(out var number) && number >= 1 ? (int)Math.Min(number, int.MaxValue) : fallback;
}
}
11 changes: 11 additions & 0 deletions backend/src/CodeSpace.Messages/Dtos/Invitations/InvitationDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ public sealed record InvitationPreview
/// could invite an arbitrary address and read the answer.</para>
/// </summary>
public required bool ForSignedInAccount { get; init; }

/// <summary>
/// <see cref="ForSignedInAccount"/> under the name the invite page that shipped before it reads. The API is replaced
/// before the SPA, so that page meets this response for the length of a rollout, and without the name it reads
/// <c>undefined</c> and asks a signed-in invitee to "Create your account" for an address that already has one.
///
/// <para>Derived, so it can never say more than <see cref="ForSignedInAccount"/> does: the same fact about the caller,
/// not the old answer to "does this address have an account". Remove it once no deployed SPA predates the rename.</para>
/// </summary>
[Obsolete("Read ForSignedInAccount. Kept for one release for the invite page that predates it.")]
public bool AccountExists => ForSignedInAccount;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public sealed record ListIssuesQuery : IQuery<IReadOnlyList<RemoteIssue>>, IRequ

/// <summary>
/// The furthest page a caller may name (with <see cref="MaxPerPage"/>, the newest 100,000 issues), as for
/// <see cref="ListPullRequestsQuery.MaxPage"/>. A page past it reads this one: no list is paged this deep.
/// <see cref="ListPullRequestsQuery.MaxPage"/>. A page past it is an empty page, and the provider is not asked: no
/// list is paged this deep.
/// </summary>
public const int MaxPage = 1000;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ public sealed record ListPullRequestsQuery : IQuery<IReadOnlyList<RemotePullRequ

/// <summary>
/// The furthest page a caller may name (with <see cref="MaxPerPage"/>, the newest 100,000 requests). A page past it
/// reads this one: the number cannot run past what a page index and offset hold, and no list is paged this deep.
/// is an empty page, and the provider is not asked: the number cannot run past what a page index and offset hold,
/// and no list is paged this deep. (Reading this page again for it would answer a caller who pages on with rows it
/// already has.)
/// </summary>
public const int MaxPage = 1000;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public async Task Accepting_over_http_creates_the_account_and_returns_a_session(
{
var (token, email) = await SeedInvitationAsync();

var response = await _factory.CreateClient().PostAsJsonAsync($"/api/invitations/{token}/accept", new { name = "Wire Newcomer", password = "correct-horse-battery" });
var response = await AcceptAsync(token, "Wire Newcomer", asBrowser: true);

response.StatusCode.ShouldBe(HttpStatusCode.OK, await DescribeAsync(response, "200 with a session"));

Expand All @@ -60,6 +60,20 @@ public async Task Accepting_over_http_creates_the_account_and_returns_a_session(
body.GetProperty("user").GetProperty("teams").GetArrayLength().ShouldBeGreaterThanOrEqualTo(2, "the invited team plus the personal workspace acceptance creates");
}

[Fact]
public async Task Accepting_without_the_csrf_header_returns_the_session_in_the_body_and_sets_no_cookie()
{
// The invite page that shipped before the cookie, running against an API that is already newer, sends no CSRF header
// and signs out by emptying its own storage. A cookie it cannot clear would outlive the sign-out for a day.
var (token, _) = await SeedInvitationAsync();

var response = await AcceptAsync(token, "Older Page Newcomer", asBrowser: false);

response.StatusCode.ShouldBe(HttpStatusCode.OK, await DescribeAsync(response, "200 with a session"));
(response.Headers.TryGetValues("Set-Cookie", out var setCookies) ? setCookies : []).ShouldNotContain(c => c.StartsWith($"{SessionCookie.Name}=", StringComparison.Ordinal));
JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.GetProperty("token").GetString().ShouldNotBeNullOrWhiteSpace("the page reads its session from the body, as it always did");
}

[Fact]
public async Task The_body_does_not_have_to_repeat_the_token_that_is_already_in_the_route()
{
Expand Down Expand Up @@ -148,6 +162,16 @@ public async Task A_malformed_accept_answers_the_same_whether_or_not_the_address

// ─── Helpers ────────────────────────────────────────────────────────────────

/// <summary>Accepts as the SPA does (with the CSRF header it sends on every request) or as a client that predates the cookie does (without).</summary>
private async Task<HttpResponseMessage> AcceptAsync(string token, string name, bool asBrowser)
{
var request = new HttpRequestMessage(HttpMethod.Post, $"/api/invitations/{token}/accept") { Content = JsonContent.Create(new { name, password = "correct-horse-battery" }) };

if (asBrowser) request.Headers.Add(SessionCookie.CsrfHeader, "1");

return await _factory.CreateClient().SendAsync(request);
}

/// <summary>
/// Seeds a pending invitation straight to the row, hashing the token the same way the service does.
/// Going through the create endpoint would need a signed-in member with members.manage, which is a
Expand Down
Loading
Loading