diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index abfc44c..e6bd0e6 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -373,6 +373,12 @@ public sealed class HeimdallAuthService( /// What the attempt this browser holds for the nonce is for, or null when it holds none. public HeimdallAttemptPurpose? PurposeOf(string? cookieValue, string? nonce) => Unprotect(cookieValue, nonce)?.Purpose; + /// The page a merge-proof attempt began on, which a refused completion goes back to; null for every other attempt. + public string? MergeReturnPathOf(string? cookieValue, string? nonce) => + Unprotect(cookieValue, nonce) is { Purpose: HeimdallAttemptPurpose.MergeProveSelf or HeimdallAttemptPurpose.MergeProveOther } attempt + ? attempt.ReturnPath.Value + : null; + /// Redeems the code of an attempt and verifies the token it carries. The token is returned to the caller's stack frame and nowhere else. private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken, HeimdallRedeemResponse Redeemed)> RedeemVerifiedAsync( string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken) @@ -572,13 +578,70 @@ public sealed class HeimdallAuthService( case HeimdallAttemptPurpose.MergeProveSelf: case HeimdallAttemptPurpose.MergeProveOther: - throw new NotImplementedException(); + return new HeimdallLinkStep( + null, cookieName, null, await RecordMergeProofAsync(callbackBaseUrl, attempt, code, cancellationToken), ReturnPath: attempt.ReturnPath.Value); default: throw new HeimdallAuthException("Heimdall completion is not a link attempt."); } } + /// + /// Completes a merge-proof sign-in: redeems the code, reads the proven account's logins with its bearer and hands both to + /// , the one place a bearer is kept. Self must prove the session's account (RedeemOwnerAsync). Other must + /// prove a different account that has a member account here, found by its Heimdall account id and nothing else. Every refusal + /// returns before Heimdall is asked for a list, and the bearer leaves scope with this call. + /// + private async Task RecordMergeProofAsync( + string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken) + { + var self = attempt.Purpose == HeimdallAttemptPurpose.MergeProveSelf; + var (identity, bearer, redeemed) = self + ? await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken) + : await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken); + if (redeemed.Provider is not null && !string.Equals(redeemed.Provider, attempt.Provider, StringComparison.Ordinal)) + { + throw new HeimdallAuthException("Heimdall completion came through another provider."); + } + + if (!self && string.Equals(identity.AccountId, attempt.ExpectedAccountId, StringComparison.Ordinal)) + { + return HeimdallLinkOutcome.MergeSameAccount; + } + + var actorId = await UserAccountIdOfAsync(attempt.ExpectedAccountId!, cancellationToken); + var sideId = self ? actorId : await UserAccountIdOfAsync(identity.AccountId, cancellationToken); + if (actorId is null || sideId is null) + { + return HeimdallLinkOutcome.MergeNoAccount; + } + + var listed = await memberClient.ListAsync(bearer, cancellationToken); + if (listed.Call != HeimdallMemberCall.Ok) + { + return OutcomeOf(listed.Call); + } + + return mergeProofs.Put( + actorId.Value, + self ? MergeSide.Self : MergeSide.Other, + new MergeProof(bearer, identity.AccountId, attempt.Provider), + sideId.Value, + listed.Identities!) switch + { + PutOutcome.Stored => HeimdallLinkOutcome.MergeProofRecorded, + PutOutcome.AmbiguousLogins => HeimdallLinkOutcome.MergeLoginsAmbiguous, + _ => HeimdallLinkOutcome.Error + }; + } + + /// The member account that holds this Heimdall account id (an equality on the id, never on a name or email), or null. + private async Task UserAccountIdOfAsync(string heimdallAccountId, CancellationToken cancellationToken) => + await dbContext.UserAccounts + .Where(x => x.HeimdallAccountId == heimdallAccountId) + .Select(x => (Guid?)x.Id) + .SingleOrDefaultAsync(cancellationToken); + /// /// Heimdall's unlink with a bearer that carries fresh proof, and what the list becomes. Only Heimdall's definitive refusals leave /// the account as the list showed it. Any other end (not found, a lost answer, a 500, a malformed 200) may have applied the diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 35da613..0d70345 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -900,7 +900,7 @@ app.MapPost("/auth/heimdall/complete", async ( } return linking - ? Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(HeimdallLinkOutcome.Error)) + ? Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(HeimdallLinkOutcome.Error, heimdallAuthService.MergeReturnPathOf(held?.Value, attempt))) : Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous();