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
13 changes: 13 additions & 0 deletions backend/src/CodeSpace.Api/Controllers/CredentialsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,19 @@ public async Task<IActionResult> Revoke([FromRoute] Guid credentialId, [FromBody
return Ok(result);
}

/// <summary>
/// Replace a credential's token in place: the credential keeps its id, so everything bound through it keeps working and
/// what its dead token had broken comes back. The new token must be the same provider account's. The token travels in
/// the body only. Route's credentialId is authoritative, merged into the command with <c>with</c> so a body that
/// disagrees cannot redirect the replacement to another credential.
/// </summary>
[HttpPut("{credentialId:guid}/token")]
public async Task<IActionResult> ReplaceToken([FromRoute] Guid credentialId, [FromBody] ReplaceCredentialTokenCommand command, CancellationToken cancellationToken)
{
var result = await _mediator.Send(command with { CredentialId = credentialId }, cancellationToken).ConfigureAwait(false);
return Ok(result);
}

/// <summary>
/// Move every repository and group hook still bound through a dead credential onto another credential in one action.
/// Route's fromCredentialId is authoritative; the body carries <c>toCredentialId</c>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ public sealed class InitCredentialOAuthCommandHandler : IRequestHandler<InitCred
public InitCredentialOAuthCommandHandler(IOAuthFlowService flow) { _flow = flow; }

public async Task<InitCredentialOAuthResult> Handle(InitCredentialOAuthCommand request, CancellationToken cancellationToken) =>
await _flow.InitAsync(request.ProviderInstanceId, request.DisplayName, request.IntendedOwnerUserId, request.ReturnUrl, request.Scopes, cancellationToken).ConfigureAwait(false);
await _flow.InitAsync(request, cancellationToken).ConfigureAwait(false);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using CodeSpace.Core.Services.Credentials;
using CodeSpace.Messages.Commands.Credentials;
using MediatR;

namespace CodeSpace.Core.Handlers.CommandHandlers.Credentials;

public sealed class ReplaceCredentialTokenCommandHandler : IRequestHandler<ReplaceCredentialTokenCommand, ReplaceCredentialTokenResult>
{
private readonly ICredentialService _credentials;

public ReplaceCredentialTokenCommandHandler(ICredentialService credentials) { _credentials = credentials; }

public async Task<ReplaceCredentialTokenResult> Handle(ReplaceCredentialTokenCommand request, CancellationToken cancellationToken) =>
await _credentials.ReplaceTokenAsync(request.CredentialId, request.Token, cancellationToken).ConfigureAwait(false);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- 0250_oauth_pending_state_replace_credential.sql
--
-- Lets a pending OAuth connect name a credential to re-authorize in place.
--
-- Every reconnect used to mint a NEW credential row, and everything bound through the old one — repositories, group
-- hooks, the act-as identity — pointed at a row whose token was dead until a whole succession subsystem moved it. A
-- connect that names the credential it replaces writes the new token into that row instead: same id, nothing to move.
-- /init stores the credential's id here; the callback reads it back and re-authorizes that row.
--
-- Nullable and additive, for the rolling deploy: a pod still on the previous version inserts rows without the column
-- (NULL here) and ignores it on the rows a newer pod wrote — it then completes the connect as a new credential, exactly as
-- it always did. A row with NULL is a plain connect for every version.
--
-- No foreign key, on purpose. A pending row lives ten minutes and is swept by the expiry job; the reference is checked
-- again against (id, team) when the callback uses it, because the credential can be retired in between, and a hard
-- constraint would only make a row that can never complete a reason for something unrelated to fail.

ALTER TABLE oauth_pending_state ADD COLUMN IF NOT EXISTS replace_credential_id UUID NULL;

COMMENT ON COLUMN oauth_pending_state.replace_credential_id IS
'The credential this connect re-authorizes in place (its token is replaced, the id kept); NULL connects a new credential. Not a foreign key: re-checked against (id, team) when the callback uses it.';
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ public class OAuthPendingState : IAuditable
public Guid? IntendedOwnerUserId { get; set; }
public string? ReturnUrl { get; set; }

/// <summary>
/// The credential the callback re-authorizes in place instead of creating another, or null for a plain connect. Written
/// by a version from migration 0250 on; a row an older pod wrote reads null, and an older pod completing a newer row
/// ignores it and creates a new credential.
/// </summary>
public Guid? ReplaceCredentialId { get; set; }

/// <summary>
/// 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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CodeSpace.Messages.Dtos.Providers;
using CodeSpace.Messages.Enums;

namespace CodeSpace.Core.Services.Credentials;
Expand Down Expand Up @@ -34,6 +35,16 @@ public static CredentialHealth Derive(CredentialStatus status, AuthType authType
/// <summary>The credentials whose provider-side expiry CodeSpace records: access tokens a person created, not tokens the provider issued for a grant.</summary>
public static bool IsStaticToken(AuthType authType) => authType is AuthType.Pat or AuthType.ProjectAccessToken or AuthType.GroupAccessToken;

/// <summary>
/// What to record about a static token's expiry from what its provider said about the token: the expiry as reported —
/// recorded even when it is "none" — but only when the provider described the token (<see cref="CredentialProbeResult.Active"/>
/// is set). Null leaves the column alone: nothing was described, or the credential is an OAuth one, whose expiry is its
/// access token's and is the refresher's to write. One rule for every writer that has just probed a token — the sweep, a
/// credential being added, a token that replaces another — so they cannot disagree about what "expires" means.
/// </summary>
public static ExpiryRecord? RecordedExpiry(AuthType authType, CredentialProbeResult? probe) =>
IsStaticToken(authType) && probe is { Active: not null } ? new ExpiryRecord(probe.ExpiresAt) : null;

private static bool HasExpired(AuthType authType, DateTimeOffset? expiresDate, DateTimeOffset now) => IsStaticToken(authType) && expiresDate is { } at && at <= now;

private static bool IsExpiringSoon(AuthType authType, DateTimeOffset? expiresDate, DateTimeOffset now) => IsStaticToken(authType) && expiresDate is { } at && at - now <= ExpiringWithin;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

namespace CodeSpace.Core.Services.Credentials;

public sealed class CredentialHealthService : ICredentialHealthService, IScopedDependency
public sealed class CredentialHealthService : ICredentialHealthService, IBoundedCredentialProbe, IScopedDependency
{
private const string SweepName = "credential-health-probe";

Expand Down Expand Up @@ -140,7 +140,7 @@ private async Task<bool> ProbeOneAsync(DueCredential candidate, CancellationToke
/// verdict in its row stops anything resolving its token, and the probe is how the verdict is lifted. The copy is read
/// without tracking and never saved, so the pretence reaches no row.
/// </summary>
private async Task<CredentialProbeResult> ProbeAsync(ProviderInstance instance, Credential credential, CancellationToken cancellationToken)
public async Task<CredentialProbeResult> ProbeAsync(ProviderInstance instance, Credential credential, CancellationToken cancellationToken)
{
var provider = instance.Provider;
var probe = _registry.Require<ICredentialProbeCapability>(provider);
Expand Down Expand Up @@ -176,7 +176,7 @@ private async Task<bool> RecordAsync(Credential credential, CredentialProbeResul

if (result.IsValid)
{
await _writer.MarkValidatedAsync(credential.Id, credential.EncryptedPayload, _clock.GetUtcNow(), ExpiryOf(credential, result), cancellationToken).ConfigureAwait(false);
await _writer.MarkValidatedAsync(credential.Id, credential.EncryptedPayload, _clock.GetUtcNow(), CredentialHealthDerivation.RecordedExpiry(credential.AuthType, result), cancellationToken).ConfigureAwait(false);

return true;
}
Expand All @@ -200,7 +200,7 @@ public async Task ValidateNewAsync(Credential credential, ProviderInstance insta

credential.LastValidatedDate = _clock.GetUtcNow();

if (ExpiryOf(credential, result) is { } expiry) credential.ExpiresDate = expiry.ExpiresAt;
if (CredentialHealthDerivation.RecordedExpiry(credential.AuthType, result) is { } expiry) credential.ExpiresDate = expiry.ExpiresAt;
}

/// <summary>The probe of a credential that is not saved yet, or null when it could not be asked: a failure here is the provider's, and adding the credential does not wait on it or fail with it. The caller's own cancellation still ends the add.</summary>
Expand All @@ -218,9 +218,5 @@ public async Task ValidateNewAsync(Credential credential, ProviderInstance insta
}
}

/// <summary>A static token's expiry as its provider reports it — recorded even when it is "none" — but only when the provider described the token; an OAuth credential's expiry is its access token's and is not this sweep's to write.</summary>
private static ExpiryRecord? ExpiryOf(Credential credential, CredentialProbeResult result) =>
CredentialHealthDerivation.IsStaticToken(credential.AuthType) && result.Active.HasValue ? new ExpiryRecord(result.ExpiresAt) : null;

private sealed record DueCredential(Guid Id) : ISweepCandidate;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using CodeSpace.Core.DependencyInjection;
using CodeSpace.Core.Settings.Database;
using CodeSpace.Messages.Credentials;
using Npgsql;
using NpgsqlTypes;

namespace CodeSpace.Core.Services.Credentials;

public sealed class CredentialReauthorizationWriter : ICredentialReauthorizationWriter, IScopedDependency
{
private const string Sql = @"UPDATE credential
SET encrypted_payload = @enc, auth_type = @authType, scopes = @scopes, expires_date = @exp,
display_name = COALESCE(@name, display_name),
status = 'Active', last_error = NULL, last_validated_date = @now,
last_modified_date = @now, last_modified_by = @user
WHERE id = @id AND deleted_date IS NULL AND encrypted_payload = @expected";

private readonly CodeSpaceConnectionString _connectionString;

public CredentialReauthorizationWriter(CodeSpaceConnectionString connectionString) { _connectionString = connectionString; }

public async Task<bool> TryReplaceAsync(CredentialReplacement replacement, CancellationToken cancellationToken)
{
// Detached Npgsql connection — must NOT join an outer EF transaction. The new token is the only copy of an
// authorization the provider will not issue again from the same code; if an outer rollback discarded it the person
// would have to authorize again.
await using var conn = new NpgsqlConnection(_connectionString.Value);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);

await using var cmd = conn.CreateCommand();
cmd.CommandText = Sql;
cmd.Parameters.AddWithValue("@enc", replacement.EncryptedPayload);
cmd.Parameters.AddWithValue("@authType", replacement.AuthType.ToString());
cmd.Parameters.Add(new NpgsqlParameter("@scopes", NpgsqlDbType.Array | NpgsqlDbType.Text) { Value = replacement.Scopes?.ToArray() ?? (object)DBNull.Value });
cmd.Parameters.Add(new NpgsqlParameter("@exp", NpgsqlDbType.TimestampTz) { Value = (object?)replacement.ExpiresDate?.ToUniversalTime() ?? DBNull.Value });
cmd.Parameters.Add(new NpgsqlParameter("@name", NpgsqlDbType.Text) { Value = (object?)replacement.DisplayName ?? DBNull.Value });
cmd.Parameters.AddWithValue("@now", DateTimeOffset.UtcNow);
cmd.Parameters.AddWithValue("@user", replacement.ModifiedBy);
cmd.Parameters.AddWithValue("@id", replacement.CredentialId);
cmd.Parameters.AddWithValue("@expected", replacement.ExpectedEncryptedPayload);

return await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false) == 1;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using CodeSpace.Messages.Enums;
using Microsoft.Extensions.Logging;

namespace CodeSpace.Core.Services.Credentials;

public sealed partial class CredentialReauthorizer
{
/// <summary>What the credential's dead token had broken comes back — the repositories it turned Error, the hooks it buried — with nothing re-pointed, because nothing moved.</summary>
private async Task ReviveBindingsAsync(CancellationToken cancellationToken)
{
var revived = await _bindings.ReviveBindingsAsync(_ctx.Credential, cancellationToken).ConfigureAwait(false);

if (revived.Any)
_logger.LogInformation("Re-authorized credential {CredentialId} in place; {RepositoryCount} repositories and {ConnectionWebhookCount} group hooks bound through it came back", _ctx.Credential.Id, revived.RepositoryCount, revived.ConnectionWebhookCount);
}

/// <summary>
/// The owner's act-as identity is pointed at this credential again — created if their Disconnect had ended it — as a
/// connect does. Only when the owner is the one who re-authorized: an identity says "this account speaks for this
/// person", and only the person can say that. Best-effort, as at connect: the credential is already valid for repository
/// operations, and a probe hiccup right after a valid token must not undo a re-authorization that has been written.
/// </summary>
private async Task RelinkActorIdentityBestEffortAsync(CancellationToken cancellationToken)
{
if (!ActorIsTheOwner(out var ownerId)) return;

try
{
await _identities.EnsureIdentityForCredentialAsync(_ctx.Instance, _ctx.Credential, ownerId, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(ex, "Re-authorized credential {CredentialId} but could not relink the act-as identity of user {UserId}; act-as stays unavailable until they re-authorize again", _ctx.Credential.Id, ownerId);
}
}

private bool ActorIsTheOwner(out Guid ownerId)
{
ownerId = _ctx.Credential.OwnerUserId ?? Guid.Empty;

return _ctx.Credential.Ownership == CredentialOwnership.Personal && ownerId != Guid.Empty && ownerId == _ctx.Request.ActorUserId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Messages.Credentials;
using CodeSpace.Messages.Dtos.Providers;
using CodeSpace.Messages.Enums;

namespace CodeSpace.Core.Services.Credentials;

public sealed partial class CredentialReauthorizer
{
private static ReauthorizationContext BuildContext(CredentialReauthorization request) => new() { Request = request };

/// <summary>The state one re-authorization builds up, step by step, and the next step reads. One per call.</summary>
private sealed class ReauthorizationContext
{
public required CredentialReauthorization Request { get; init; }

/// <summary>The row, read untracked: as it was before the write until <see cref="CredentialReauthorizer.MarkReauthorized"/> brings it up to date.</summary>
public Credential Credential { get; set; } = default!;

public ProviderInstance Instance => Credential.ProviderInstance;

/// <summary>The auth type the row had before this re-authorization — what its replaced payload must be read as.</summary>
public AuthType PreviousAuthType { get; set; }

public string NewEncryptedPayload { get; set; } = string.Empty;

/// <summary>The provider's answer for the new token; null when the provider has no probe or the probe could not be run.</summary>
public CredentialProbeResult? Profile { get; set; }

/// <summary>The provider account the credential was connected as, when something durable says so.</summary>
public KnownAccount? Known { get; set; }

/// <summary>What the row held when it was replaced — the payload the write was compared against.</summary>
public string ReplacedEncryptedPayload { get; set; } = string.Empty;
}

private sealed record KnownAccount(string ProviderUserId, string ProviderUsername);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using CodeSpace.Messages.Credentials;

namespace CodeSpace.Core.Services.Credentials;

public sealed partial class CredentialReauthorizer
{
public enum AccountMatch
{
/// <summary>Nothing both sides know to compare — the provider gave no id and no name, or the credential has none on record.</summary>
Unknown,
Same,
Different
}

/// <summary>
/// Whether the account that signed in is the account on record. The provider's own id is authoritative where both sides
/// have one — handles are renamed, ids are not — and the handle, compared without regard to case as the providers do, is
/// the fallback when either side lacks an id.
/// </summary>
internal static AccountMatch Compare(string? knownId, string? knownName, string? presentedId, string? presentedName)
{
if (!string.IsNullOrWhiteSpace(knownId) && !string.IsNullOrWhiteSpace(presentedId)) return knownId == presentedId ? AccountMatch.Same : AccountMatch.Different;

if (!string.IsNullOrWhiteSpace(knownName) && !string.IsNullOrWhiteSpace(presentedName)) return string.Equals(knownName, presentedName, StringComparison.OrdinalIgnoreCase) ? AccountMatch.Same : AccountMatch.Different;

return AccountMatch.Unknown;
}

/// <summary>
/// The OAuth tokens the re-authorization replaced and the provider should be asked to end, or null when there is nothing to
/// end: the row held no OAuth tokens, or the provider handed the new authorization the very tokens it already held — one
/// grant reused for a second sign-in, as GitLab through 15.4 does — and revoking those would revoke the new ones.
/// </summary>
internal static OAuthPayload? ReplacedPairToRevoke(CredentialPayload? replaced, CredentialPayload next)
{
if (replaced is not OAuthPayload pair) return null;

if (next is not OAuthPayload fresh) return pair;

var sharesAToken = pair.AccessToken == fresh.AccessToken || (!string.IsNullOrEmpty(pair.RefreshToken) && pair.RefreshToken == fresh.RefreshToken);

return sharesAToken ? null : pair;
}
}
Loading
Loading