From 18f1ec6aca6243fef3b2ee590bfb0a60d55a8b2c Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 13 Nov 2026 20:23:54 +0000 Subject: [PATCH] Linked accounts: drop the list after an unlink with an uncertain end; Google never mints; docs Co-Authored-By: Claude Sonnet 5.5 --- docs/linked-accounts.md | 10 +-- .../Features/Heimdall/HeimdallAuthService.cs | 17 ++--- .../Pages/Membership/LinkedAccounts.cshtml | 9 +-- .../Bifrost.Web.Tests/LinkedAccountsTests.cs | 71 ++++++++++++++++++- .../Support/HeimdallTestKit.cs | 17 ++++- 5 files changed, 102 insertions(+), 22 deletions(-) diff --git a/docs/linked-accounts.md b/docs/linked-accounts.md index 1d5636f..4ecf3e9 100644 --- a/docs/linked-accounts.md +++ b/docs/linked-accounts.md @@ -22,11 +22,11 @@ Every action is a POST with antiforgery; a GET starts nothing. The completion ro ## Which provider proves the account -An ordinary proof sign-in through a provider the account no longer holds makes Heimdall mint a new, unrelated account that holds that login, and the freed login then answers `identity_linked_elsewhere` when the member tries to link it back. So Bifrost never guesses a provider, and it does not use the "provider you signed in with" claim in the session (that records how you signed in once, not what the account holds now): +An ordinary proof sign-in through Discord or Patreon when the account no longer holds that login makes Heimdall mint a new, unrelated account that holds it (those providers are `mints_account`), and the freed login then answers `identity_linked_elsewhere` when the member tries to link it back. Google is `requires_link`: Heimdall refuses a Google sign-in for a login no account holds (`identity_not_linked`) and never mints. So Bifrost never guesses a provider, and it does not use the "provider you signed in with" claim in the session (that records how you signed in once, not what the account holds now). This guarantee covers this page's proofs only: the older anonymous `GET /auth/heimdall/{provider}` still starts an ordinary sign-in through any named provider, and can overwrite an in-flight attempt slot; that route is unchanged here. - With a current list (the five-minute snapshot), the proof goes through a provider on it: Discord, else Patreon, else Google. -- With no list (first visit, five minutes passed, a restart), the page offers only "Show my logins through Discord / Patreon / Google" and says plainly that Bifrost cannot tell what the account holds, so the member picks a login they know it holds. A named provider the account turns out not to hold still mints at Heimdall: that is the member's choice and the page says so beforehand. A refresh with no list and no named provider starts nothing (`provider_needed`). -- Link buttons appear only with a current list; a posted link with none starts nothing (`list_expired`). Unlink needs the list too. +- With no list (first visit, five minutes passed, a restart), the page offers only "Show my logins through Google (cannot create an account) / Discord / Patreon" and says plainly that Bifrost cannot tell what the account holds, so the member picks a login they know it holds. A named Discord or Patreon the account turns out not to hold still mints at Heimdall: that is the member's choice and the page says so beforehand. A refresh with no list and no named provider starts nothing (`provider_needed`). +- Link buttons appear only with a current list; a posted link with none starts nothing (`list_expired`). Unlink needs the list too. After an unlink whose result is not a definitive refusal (a 500, a lost or malformed answer, not found) the list is dropped, since Heimdall may have applied it; only `403 step_up_required`, `409 last_identity` and `401` leave it. ## The proof limit @@ -58,4 +58,6 @@ A link refused because the login already belongs to another account (`409 identi ## Known leftover -When Discord or Patreon's callback refuses a link because the login belongs to another account, the complete page's script redirects to the page with the fixed result and does not post anything. The held attempt cookie (`__Host-bifrost_heimdall_`, HttpOnly, so script cannot clear it) stays until its ten minutes end and occupies one of the two slots meanwhile; a new start reuses a free or the oldest slot, so nothing blocks. Clearing it would need a server route reached by GET or a post from the failure path, which the hardened script deliberately does not do. +Several exits leave the held attempt cookie (`__Host-bifrost_heimdall_`, HttpOnly, so script cannot clear it) in the browser until its ten minutes end: the complete page's redirect when Discord or Patreon refuses a link because the login belongs to another account, every other `heimdall_status=error` redirect, and a return with no code or no attempt. The script redirects without posting anything; clearing the cookie would need a server route reached by GET or a post from the failure path, which the hardened script deliberately does not do. + +A start takes a free slot, else overwrites the slot that expires first, live or dead (`ChooseSlot`, two slots). So three member starts within ten minutes can cost a still-live older attempt, which then finds no cookie and is refused; the member starts again. diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index e3c0f39..12e028e 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -164,7 +164,7 @@ public sealed class HeimdallAuthService( /// /// The provider an ordinary proof sign-in uses, or null when none is safe. A sign-in through a provider the account no - /// longer holds makes Heimdall mint an unrelated account that holds that login, so it is never guessed: with the account's + /// longer holds makes Heimdall mint an unrelated account that holds that login (Discord and Patreon; Google refuses instead), so it is never guessed: with the account's /// current list, the first held one of the person's choice, Discord, Patreon, Google; with no list, only a provider the /// member named (), who is the one who knows what they hold. The session's provider claim is not /// consulted: it records how they signed in once, not what the account holds now. @@ -473,14 +473,15 @@ public sealed class HeimdallAuthService( } var unlinked = await memberClient.UnlinkAsync(bearer, attempt.Target ?? string.Empty, cancellationToken); - switch (unlinked.Call) + // 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 unlink, so the list is dropped rather than trusted. + if (unlinked.Call == HeimdallMemberCall.Ok) { - case HeimdallMemberCall.Ok: - snapshots.Put(identity.AccountId, unlinked.Identities!); - break; - case HeimdallMemberCall.IdentityNotFound: - snapshots.Remove(identity.AccountId); - break; + snapshots.Put(identity.AccountId, unlinked.Identities!); + } + else if (unlinked.Call is not (HeimdallMemberCall.SignInRequired or HeimdallMemberCall.StepUpRequired or HeimdallMemberCall.LastIdentity)) + { + snapshots.Remove(identity.AccountId); } return new HeimdallLinkStep(null, cookieName, null, unlinked.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Unlinked : OutcomeOf(unlinked.Call)); diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml index 6a596c3..c27ccc6 100644 --- a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml @@ -32,15 +32,16 @@

Bifrost does not hold the list: it asks Heimdall, which means signing in to Heimdall again, and keeps the answer for five minutes. It also cannot tell which logins this account holds right now, so it will not choose one for you. - Pick a login you know it holds. Signing in through one it no longer holds would create a separate Heimdall account - holding that login. + Pick a login you know it holds. Signing in through Discord or Patreon when the account no longer holds that login + would create a separate Heimdall account holding it. Google cannot do that: Heimdall refuses a Google sign-in for a + login no account holds.

- @foreach (var provider in Model.LinkableProviders) + @foreach (var provider in Model.LinkableProviders.OrderBy(x => x == "google" ? 0 : 1)) {
- +
}
diff --git a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs index 098f8a6..98a8993 100644 --- a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs +++ b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs @@ -133,6 +133,71 @@ public sealed class LinkedAccountsTests Assert.DoesNotContain("Link Patreon", html); } + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.OK)] + public async Task An_unlink_whose_answer_is_lost_drops_the_list_and_a_link_then_starts_nothing(HttpStatusCode lost) + { + using var host = new HeimdallHost(realSessions: true); + var member = await ListedMember(host, "discord", "google"); + var discord = (string)host.Heimdall.Identities[0]["id"]!; + var start = await host.StartUnlinkAsync(member.Session, discord); + host.Heimdall.UnlinkApplyThenFail = lost; + + var done = await Finish(host, member.Session, start, member.Account, provider: "google"); + var starts = host.Heimdall.StartBodies.Count; + var link = await host.StartLinkAsync(member.Session, "patreon", seedList: false); + var (_, html) = await host.GetLinkedAccountsAsync(member.Session); + + // Heimdall did unlink Discord. Linking now through the stale list would sign in through Discord and mint an orphan. + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Single(host.Heimdall.Identities); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Page + "?result=list_expired", Location(link.Response)); + Assert.Empty(host.Heimdall.OrphanMints); + Assert.Contains("will not choose one for you", html); + } + + [Fact] + public async Task Without_a_list_the_picker_names_Google_first_as_unable_to_create_an_account() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + + var (_, html) = await host.GetLinkedAccountsAsync(session); + + Assert.Contains("Google cannot do that", html); + Assert.Contains("through Google (cannot create an account)", html); + Assert.True(html.IndexOf("Show my logins through Google", StringComparison.Ordinal) < html.IndexOf("Show my logins through Discord", StringComparison.Ordinal)); + } + + [Fact] + public void With_no_list_and_no_choice_there_is_no_proof_provider() + { + Assert.Null(HeimdallAuthService.ProofProvider(null, null)); + Assert.Null(HeimdallAuthService.ProofProvider(null, "twitch")); + Assert.Equal("patreon", HeimdallAuthService.ProofProvider(null, "Patreon")); + Assert.Null(HeimdallAuthService.ProofProvider([], "discord")); + Assert.Null(HeimdallAuthService.ProofProvider(["twitch"], null)); + Assert.Equal("patreon", HeimdallAuthService.ProofProvider(["google", "patreon"], null)); + } + + [Fact] + public async Task The_service_refuses_to_start_a_proof_with_no_list_and_no_choice() + { + using var host = new HeimdallHost(realSessions: true); + using var scope = host.Services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var starts = host.Heimdall.StartBodies.Count; + + await Assert.ThrowsAsync(() => service.StartListAsync( + HeimdallShapes.NewAccountId(), null, "https://localhost", [], CancellationToken.None)); + await Assert.ThrowsAsync(() => service.StartLinkAsync( + HeimdallShapes.NewAccountId(), "patreon", "https://localhost", [], CancellationToken.None)); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + } + [Theory] [InlineData(null)] [InlineData("twitch")] @@ -493,8 +558,8 @@ public sealed class LinkedAccountsTests Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines)); var (_, html) = await host.GetLinkedAccountsAsync(member.Session); Assert.DoesNotContain("HEIMDALL FREE TEXT", html); - // Not found says the list is stale: it is dropped. Anything else leaves what was shown. - Assert.Equal(status != HttpStatusCode.NotFound, html.Contains("Name of discord")); + // Only a definitive refusal (401 here) leaves the list shown; not found or an unreadable end may have changed the account, so it is dropped. + Assert.Equal(status == HttpStatusCode.Unauthorized, html.Contains("Name of discord")); } [Fact] @@ -508,7 +573,7 @@ public sealed class LinkedAccountsTests var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google"); Assert.Equal(Page + "?result=error", Location(done)); - Assert.Contains("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html); + Assert.DoesNotContain("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html); } [Fact] diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index ced87d5..7299221 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -251,8 +251,8 @@ public sealed class FakeHeimdall : HttpMessageHandler public HashSet StepUpReturnTos { get; } = new(StringComparer.Ordinal); /// - /// Providers an ordinary sign-in came through that the fake account (once it holds any login) does not hold. Heimdall mints an - /// unrelated account for such a sign-in, which would then hold that login. + /// Discord or Patreon sign-ins that came through a provider the fake account (once it holds any login) does not hold. Heimdall mints an + /// unrelated account for such a sign-in, which would then hold that login. Google never mints. /// public List OrphanMints { get; } = []; @@ -265,6 +265,9 @@ public sealed class FakeHeimdall : HttpMessageHandler /// When set, every unlink answers this status with Heimdall's error code for it (the fake's own rules are bypassed). public HttpStatusCode? UnlinkStatus { get; set; } + /// When set, an unlink is applied and then answered with this status (200 answers a body that is not JSON): a lost answer. + public HttpStatusCode? UnlinkApplyThenFail { get; set; } + /// Throws instead of answering a member route, as an unreachable Heimdall does. public Exception? MemberFailure { get; set; } @@ -304,6 +307,13 @@ public sealed class FakeHeimdall : HttpMessageHandler return Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}"""); } + if (UnlinkApplyThenFail is { } lost) + { + // The unlink happened; the answer did not arrive intact. + Identities.RemoveAll(x => (string?)x["id"] == id); + return Json(lost, lost == HttpStatusCode.OK ? "not json" : """{"error":"server_error"}"""); + } + if (UnlinkStatus is { } forced) { return forced switch @@ -453,7 +463,8 @@ public sealed class FakeHeimdall : HttpMessageHandler }; } - if (Identities.Count > 0 && Identities.All(x => (string?)x["provider"] != startProvider)) + // Discord and Patreon mint an account for an identity no account holds; Google is requires_link and refuses (identity_not_linked). + if (startProvider != "google" && Identities.Count > 0 && Identities.All(x => (string?)x["provider"] != startProvider)) { OrphanMints.Add(startProvider); }