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
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CodeSpace.Core.Services.OAuth;
using CodeSpace.Messages.Commands.Credentials;
using MediatR;
using Microsoft.AspNetCore.Authorization;
Expand All @@ -13,6 +14,11 @@ namespace CodeSpace.Api.Controllers;
/// is two halves: the state (32-byte CSPRNG, one-time use, 10-min TTL, PK-indexed) and a nonce
/// cookie /init set in the browser that started the flow. State alone let whichever browser
/// presented it finish someone else's connect.
///
/// <para>A browser cannot always keep that cookie: a cross-site fetch response's SameSite=Lax cookie is never stored,
/// so with the SPA and the API on different sites — and a connect started on another host than <c>OAuth:CallbackUrl</c>
/// names — the callback arrives without it. It then consumes nothing and hands the code and state to the SPA's
/// completion page, which finishes the connect through <c>POST /complete</c> as the signed-in person who started it.</para>
/// </summary>
[ApiController]
[Route("api/credentials/oauth")]
Expand Down Expand Up @@ -47,10 +53,25 @@ public async Task<IActionResult> Callback([FromQuery] OAuthCallbackQuery query,

var result = await _mediator.Send(new CompleteCredentialOAuthCommand { Code = query.Code, State = query.State, BrowserNonce = Request.Cookies[BrowserNonceCookie] }, cancellationToken).ConfigureAwait(false);

// Handed off: nothing was consumed, and a cookie this browser does hold belongs to another connect, so it stays.
if (result.CredentialId is not { } credentialId) return Redirect(OAuthReturnUrl.HandOff(result.ReturnUrl, query.Code, query.State));

// Spent with the state it proved.
Response.Cookies.Delete(BrowserNonceCookie, BrowserNonceCookieOptions(result.BrowserNonceCookiePath, lifetime: null));

return Redirect(BuildSuccessReturn(result.ReturnUrl, result.CredentialId));
return Redirect(BuildSuccessReturn(result.ReturnUrl, credentialId));
}

/// <summary>
/// The SPA's completion page finishing a connect the callback handed off. Authenticated — the fallback policy — and
/// accepted only from the person who started the connect.
/// </summary>
[HttpPost("complete")]
public async Task<IActionResult> Complete([FromBody] CompleteCredentialOAuthAsInitiatorCommand command, CancellationToken cancellationToken)
{
var result = await _mediator.Send(command, cancellationToken).ConfigureAwait(false);

return Ok(new { credentialId = result.CredentialId });
}

/// <summary>
Expand Down
5 changes: 1 addition & 4 deletions backend/src/CodeSpace.Api/Extensions/CorsPolicyExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ public static class CorsPolicyExtension
/// <summary>Named rather than the default policy so <c>UseCors</c> states which policy it is applying, and a second policy (an admin surface, a webhook origin) can be added later without silently widening this one.</summary>
public const string PolicyName = "CodeSpaceSpa";

/// <summary>The Vite dev-server origins, added in Development only. The port is pinned by vite.config.ts — keep these in sync.</summary>
private static readonly string[] DevelopmentOrigins = { "http://localhost:5180", "http://127.0.0.1:5180" };

public static IServiceCollection AddCorsPolicy(this IServiceCollection services, IConfiguration configuration, IHostEnvironment environment)
{
var origins = ResolveOrigins(configuration, environment);
Expand Down Expand Up @@ -46,6 +43,6 @@ internal static string[] ResolveOrigins(IConfiguration configuration, IHostEnvir

if (!environment.IsDevelopment()) return configured;

return configured.Concat(DevelopmentOrigins).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
return configured.Concat(AllowableCorsOriginsSetting.DevelopmentOrigins).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
}
2 changes: 1 addition & 1 deletion backend/src/CodeSpace.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"CallbackUrl": "http://localhost:5180/api/credentials/oauth/callback"
},
"Cors": {
"_doc": "Origins allowed to call this API cross-origin (when the SPA bypasses the Vite /api proxy). localhost:5180 is added automatically in Development (matches vite.config.ts server.port); production deployments must list their SPA host explicitly.",
"_doc": "Origins allowed to call this API cross-origin (when the SPA bypasses the Vite /api proxy). localhost:5180 and 127.0.0.1:5180 are added automatically in Development (matches vite.config.ts server.port); production deployments must list their SPA host explicitly. An OAuth connect may also return the browser to any origin listed here, as to App:PublicBaseUrl and to OAuth:CallbackUrl's own origin.",
"AllowedOrigins": []
},
"Logging": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using CodeSpace.Core.Services.OAuth;
using CodeSpace.Messages.Commands.Credentials;
using MediatR;

namespace CodeSpace.Core.Handlers.CommandHandlers.Credentials;

public sealed class CompleteCredentialOAuthAsInitiatorCommandHandler : IRequestHandler<CompleteCredentialOAuthAsInitiatorCommand, CompleteCredentialOAuthResult>
{
private readonly IOAuthFlowService _flow;

public CompleteCredentialOAuthAsInitiatorCommandHandler(IOAuthFlowService flow) { _flow = flow; }

public async Task<CompleteCredentialOAuthResult> Handle(CompleteCredentialOAuthAsInitiatorCommand request, CancellationToken cancellationToken) =>
await _flow.CompleteAsInitiatorAsync(request.State, request.Code, cancellationToken).ConfigureAwait(false);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ public class OAuthPendingState : IAuditable
public string? ReturnUrl { get; set; }

/// <summary>
/// SHA-256 of the nonce the starting browser holds in its binding cookie. The callback must present that nonce,
/// so only the browser that started a connect can finish it. Null only on a row a previous version wrote, which
/// therefore can never be completed (migration 0246).
/// SHA-256 of the nonce the starting browser holds in its binding cookie. A callback that presents that nonce finishes
/// the connect on the spot; one that does not consumes nothing and is handed to the SPA, where only
/// <see cref="InitiatorUserId"/>, signed in, can finish it. Null only on a row a version before migration 0246 wrote,
/// which therefore only that hand-off can complete.
/// </summary>
public string? BrowserNonceHash { get; set; }
public List<string>? RequestedScopes { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ namespace CodeSpace.Core.Services.OAuth;
/// provider's authorize URL the SPA opens in a popup.
/// 2. <see cref="CompleteAsync"/> runs from the callback redirect — anonymous, with the
/// state token and the starting browser's nonce as proof — consumes the state,
/// exchanges the auth code, and persists a Credential.
/// exchanges the auth code, and persists a Credential. Without the nonce it consumes
/// nothing and hands the callback to the SPA's completion page instead.
/// 3. <see cref="CompleteAsInitiatorAsync"/> is that page finishing it: the same consume,
/// exchange and persist, with the signed-in caller — who must have started the
/// connect — as the proof in place of the nonce.
/// </summary>
public interface IOAuthFlowService
{
Task<InitCredentialOAuthResult> InitAsync(Guid providerInstanceId, string displayName, Guid? intendedOwnerUserId, string? returnUrl, IReadOnlyList<string>? scopes, CancellationToken cancellationToken);
Task<CompleteCredentialOAuthResult> CompleteAsync(string state, string code, string? browserNonce, CancellationToken cancellationToken);
Task<CompleteCredentialOAuthResult> CompleteAsInitiatorAsync(string state, string code, CancellationToken cancellationToken);
}
9 changes: 9 additions & 0 deletions backend/src/CodeSpace.Core/Services/OAuth/IOAuthStateStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ public interface IOAuthStateStore
/// browser that started the connect can still finish it.
/// </summary>
Task<OAuthPendingState?> ConsumeAsync(string state, string browserNonceHash, CancellationToken cancellationToken);

/// <summary>
/// The same one-time consume, bound to the person instead of the browser: only the row whose state matches AND
/// whose <c>InitiatorUserId</c> is the caller. Anyone else presenting the state matches nothing and consumes nothing.
/// </summary>
Task<OAuthPendingState?> ConsumeForInitiatorAsync(string state, Guid initiatorUserId, CancellationToken cancellationToken);

/// <summary>The unexpired row for this state, read without consuming it. Null when there is none.</summary>
Task<OAuthPendingState?> FindLiveAsync(string state, CancellationToken cancellationToken);
}

public sealed record OAuthPendingStateInput
Expand Down
71 changes: 54 additions & 17 deletions backend/src/CodeSpace.Core/Services/OAuth/OAuthFlowService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using CodeSpace.Core.Services.Providers.Identity;
using CodeSpace.Core.Services.Teams;
using CodeSpace.Core.Settings.Application;
using CodeSpace.Core.Settings.CorsPolicy;
using CodeSpace.Core.Settings.OAuth;
using CodeSpace.Messages.Commands.Credentials;
using CodeSpace.Messages.Credentials;
Expand Down Expand Up @@ -34,9 +35,9 @@ public sealed class OAuthFlowService : IOAuthFlowService, IScopedDependency
private readonly ICredentialSuccessionService _succession;
private readonly ITeamMemberService _members;
private readonly OAuthCallbackUrlSetting _callbackUrlSetting;
private readonly PublicBaseUrlSetting _publicBaseUrl;
private readonly OAuthReturnOriginsSetting _returnOrigins;

public OAuthFlowService(CodeSpaceDbContext db, IOAuthClientRegistry oauthClients, IOAuthStateStore stateStore, IPkceGenerator pkce, IPayloadEncryptor encryptor, ICredentialPayloadSerializer serializer, ICurrentTeam currentTeam, ICurrentUser currentUser, IUserProviderIdentityService identityService, ICredentialSuccessionService succession, ITeamMemberService members, OAuthCallbackUrlSetting callbackUrlSetting, PublicBaseUrlSetting publicBaseUrl)
public OAuthFlowService(CodeSpaceDbContext db, IOAuthClientRegistry oauthClients, IOAuthStateStore stateStore, IPkceGenerator pkce, IPayloadEncryptor encryptor, ICredentialPayloadSerializer serializer, ICurrentTeam currentTeam, ICurrentUser currentUser, IUserProviderIdentityService identityService, ICredentialSuccessionService succession, ITeamMemberService members, OAuthCallbackUrlSetting callbackUrlSetting, OAuthReturnOriginsSetting returnOrigins)
{
_db = db;
_oauthClients = oauthClients;
Expand All @@ -50,7 +51,7 @@ public OAuthFlowService(CodeSpaceDbContext db, IOAuthClientRegistry oauthClients
_succession = succession;
_members = members;
_callbackUrlSetting = callbackUrlSetting;
_publicBaseUrl = publicBaseUrl;
_returnOrigins = returnOrigins;
}

public async Task<InitCredentialOAuthResult> InitAsync(Guid providerInstanceId, string displayName, Guid? intendedOwnerUserId, string? returnUrl, IReadOnlyList<string>? scopes, CancellationToken cancellationToken)
Expand Down Expand Up @@ -95,7 +96,23 @@ public async Task<InitCredentialOAuthResult> InitAsync(Guid providerInstanceId,

public async Task<CompleteCredentialOAuthResult> CompleteAsync(string state, string code, string? browserNonce, CancellationToken cancellationToken)
{
var pending = await ConsumeStateAsync(state, browserNonce, cancellationToken).ConfigureAwait(false);
var pending = await ConsumeBrowserBoundStateAsync(state, browserNonce, cancellationToken).ConfigureAwait(false);

if (pending == null) return await HandOffToInitiatorAsync(state, cancellationToken).ConfigureAwait(false);

return await FinishAsync(pending, code, cancellationToken).ConfigureAwait(false);
}

public async Task<CompleteCredentialOAuthResult> CompleteAsInitiatorAsync(string state, string code, CancellationToken cancellationToken)
{
var pending = await ConsumeInitiatorBoundStateAsync(state, cancellationToken).ConfigureAwait(false);

return await FinishAsync(pending, code, cancellationToken).ConfigureAwait(false);
}

/// <summary>Exchanges the code for the consumed state and keeps the token — whichever proof consumed it.</summary>
private async Task<CompleteCredentialOAuthResult> FinishAsync(OAuthPendingState pending, string code, CancellationToken cancellationToken)
{
var instance = await LoadInstanceAsync(pending.ProviderInstanceId, cancellationToken).ConfigureAwait(false);
var (clientId, clientSecret) = ReadOAuthClientCredentials(instance);

Expand All @@ -116,8 +133,8 @@ public async Task<CompleteCredentialOAuthResult> CompleteAsync(string state, str
{
CredentialId = credential.Id,
TeamId = pending.TeamId,
// Checked again rather than trusted from init: the public origin can change while a connect is pending.
ReturnUrl = OAuthReturnUrl.AllowedOrFallback(pending.ReturnUrl, _publicBaseUrl.Value),
// Checked again rather than trusted from init: the configured origins can change while a connect is pending.
ReturnUrl = OAuthReturnUrl.AllowedOrFallback(pending.ReturnUrl, _returnOrigins.Value),
BrowserNonceCookiePath = ResolveCallbackUri().AbsolutePath
};
}
Expand All @@ -129,9 +146,9 @@ public async Task<CompleteCredentialOAuthResult> CompleteAsync(string state, str
/// </summary>
private void EnsureReturnUrlAllowed(string? returnUrl)
{
if (returnUrl == null || OAuthReturnUrl.IsAllowed(returnUrl, _publicBaseUrl.Value)) return;
if (returnUrl == null || OAuthReturnUrl.IsAllowed(returnUrl, _returnOrigins.Value)) return;

throw new ArgumentException("returnUrl must be a path on this site or an address on its public origin.", nameof(returnUrl));
throw new ArgumentException($"returnUrl must be a path on this site, or an address on an origin this deployment serves its web app from — {PublicBaseUrlSetting.ConfigurationKey}, or one listed in {AllowableCorsOriginsSetting.ConfigurationKey}.", nameof(returnUrl));
}

/// <summary>
Expand Down Expand Up @@ -214,23 +231,43 @@ private async Task<ProviderInstance> LoadOwnedInstanceAsync(Guid id, Cancellatio
// Complete-internals (callback runs anonymous, no current-team check).

/// <summary>
/// Consumes the state only together with the nonce of the browser that started the flow. A callback from any
/// other browser — the person an authorize link was forwarded to, or nobody at all — matches no row, so nothing
/// is consumed, exchanged or persisted and the starting browser can still finish. One message for every refusal:
/// telling "wrong browser" from "no such state" would only confirm which states are live.
/// The fast path: consumes the state only together with the nonce of the browser that started the flow. A callback
/// from any other browser — the person an authorize link was forwarded to, a browser that could not keep the cookie,
/// or nobody at all — matches no row, so nothing is consumed, exchanged or persisted here.
/// </summary>
private async Task<OAuthPendingState> ConsumeStateAsync(string state, string? browserNonce, CancellationToken cancellationToken)
private async Task<OAuthPendingState?> ConsumeBrowserBoundStateAsync(string state, string? browserNonce, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(browserNonce))
throw UnusableState();
if (string.IsNullOrEmpty(browserNonce)) return null;

var row = await _stateStore.ConsumeAsync(state, OAuthBrowserNonce.Hash(browserNonce), cancellationToken).ConfigureAwait(false);
return await _stateStore.ConsumeAsync(state, OAuthBrowserNonce.Hash(browserNonce), cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// A callback with no binding for its state still consumes nothing: it is handed to the SPA's completion page, where
/// the signed-in person who started the connect finishes it (<see cref="CompleteAsInitiatorAsync"/>). That page is on
/// an origin the deployment serves its web app from, never wherever the starter asked — the code rides along, and a
/// forwarded authorize link must not deliver someone's code to its starter. A state that is not live is refused with
/// the one message every refusal shares: telling "wrong browser" from "no such state" would only confirm which
/// states are live.
/// </summary>
private async Task<CompleteCredentialOAuthResult> HandOffToInitiatorAsync(string state, CancellationToken cancellationToken)
{
var pending = await _stateStore.FindLiveAsync(state, cancellationToken).ConfigureAwait(false) ?? throw UnusableState();

return row ?? throw UnusableState();
return new CompleteCredentialOAuthResult { ReturnUrl = OAuthReturnUrl.CompletionPageFor(pending.ReturnUrl, _returnOrigins.Value), BrowserNonceCookiePath = ResolveCallbackUri().AbsolutePath };
}

/// <summary>
/// Consumes the state only for the person who started the connect. Anyone else — the signed-in victim of a forwarded
/// authorize link — matches no row, so nothing is consumed and the starter can still finish.
/// </summary>
private async Task<OAuthPendingState> ConsumeInitiatorBoundStateAsync(string state, CancellationToken cancellationToken) =>
await _stateStore.ConsumeForInitiatorAsync(state, _currentUser.Id!.Value, cancellationToken).ConfigureAwait(false) ?? throw NotStartedByCaller();

private static OAuthCallbackException UnusableState() => new("state token missing, expired, already consumed, or not started in this browser");

private static OAuthCallbackException NotStartedByCaller() => new("state token missing, expired, already consumed, or not started by you");

private async Task<ProviderInstance> LoadInstanceAsync(Guid id, CancellationToken cancellationToken) =>
await _db.ProviderInstance.FirstOrDefaultAsync(p => p.Id == id && p.DeletedDate == null, cancellationToken).ConfigureAwait(false)
?? throw new OAuthCallbackException($"provider instance {id} no longer exists");
Expand Down
Loading
Loading