From 95416653babfb0be4806062a63600175ad8376ba Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 15:21:40 +0000 Subject: [PATCH 1/3] Google link: tests and fake Heimdall link routes (red until the flow lands) Co-Authored-By: Claude Sonnet 5.5 --- .../Fixtures/complete-page-harness.cjs | 7 +- .../HeimdallCompletePageScriptTests.cs | 26 +- .../HeimdallGoogleLinkTests.cs | 493 ++++++++++++++++++ .../Support/HeimdallTestKit.cs | 175 ++++++- 4 files changed, 695 insertions(+), 6 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs diff --git a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs index aeaf092..4ae3eae 100644 --- a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs +++ b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs @@ -35,12 +35,13 @@ function run(name, search, hash) { }, }; const form = { - elements: { attempt: { value: "" }, code: { value: "" } }, + elements: { attempt: { value: "" }, code: { value: "" }, pendingLinkId: { value: "" } }, submit() { events.push("submit"); submit = { attempt: form.elements.attempt.value, code: form.elements.code.value, + pendingLinkId: form.elements.pendingLinkId.value, href: location.href, hash: location.hash, }; @@ -59,6 +60,10 @@ console.log( run("error-status-with-code", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied" + complete), run("no-code", "?attempt=NONCE", "#heimdall_status=success"), run("no-attempt", "", "#heimdall_status=success" + complete), + run("link-return", "?attempt=NONCE", "#heimdall_status=success&heimdall_pending_link_id=PEND1"), + run("link-return-with-decoy-query-id", "?attempt=NONCE&heimdall_pending_link_id=QPEND", "#heimdall_status=success&heimdall_pending_link_id=PEND1"), + run("link-error-with-pending-id", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied&heimdall_pending_link_id=PEND1"), + run("link-pending-id-in-query-only", "?attempt=NONCE&heimdall_pending_link_id=QPEND", ""), run("code-in-query-only", "?attempt=NONCE&heimdall_completion_code=QCODE", ""), ]), ); diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs index da7ad9c..ba2b1fb 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs @@ -15,7 +15,7 @@ public sealed class HeimdallCompletePageScriptTests private sealed record Run(string Name, string[] Events, Submit? Submit); - private sealed record Submit(string Attempt, string Code, string Href, string Hash); + private sealed record Submit(string Attempt, string Code, string Href, string Hash, string PendingLinkId); private static async Task> RunScenariosAsync() { @@ -81,7 +81,7 @@ public sealed class HeimdallCompletePageScriptTests { var runs = await RunScenariosAsync(); - Assert.Equal(new Submit("NONCE", "CODE123", "/auth/heimdall/complete?attempt=NONCE", ""), runs["success"].Submit); + Assert.Equal(new Submit("NONCE", "CODE123", "/auth/heimdall/complete?attempt=NONCE", "", ""), runs["success"].Submit); Assert.Equal("CODE123", runs["success-with-decoy-query-code"].Submit!.Code); var queryOnly = runs["code-in-query-only"]; @@ -90,6 +90,28 @@ public sealed class HeimdallCompletePageScriptTests Assert.Equal(ErrorTarget, queryOnly.Events[^1]); } + [Fact] + public async Task A_pending_link_id_is_read_only_from_the_fragment_and_posted_without_a_code() + { + var runs = await RunScenariosAsync(); + + foreach (var name in new[] { "link-return", "link-return-with-decoy-query-id" }) + { + var run = runs[name]; + Assert.Equal(new Submit("NONCE", "", run.Submit!.Href, "", "PEND1"), run.Submit); + Assert.DoesNotContain("PEND1", run.Submit.Href); + Assert.StartsWith("replaceState:", run.Events[0]); + Assert.DoesNotContain("PEND1", run.Events[0]); + Assert.DoesNotContain("#", run.Events[0]); + Assert.Equal("submit", run.Events[1]); + } + + Assert.Null(runs["link-pending-id-in-query-only"].Submit); + Assert.Equal(ErrorTarget, runs["link-pending-id-in-query-only"].Events[^1]); + Assert.Null(runs["link-error-with-pending-id"].Submit); + Assert.Equal(ErrorTarget, runs["link-error-with-pending-id"].Events[^1]); + } + [Theory] [InlineData("error-status-with-code")] [InlineData("no-code")] diff --git a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs new file mode 100644 index 0000000..deaf751 --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs @@ -0,0 +1,493 @@ +using System.Net; +using System.Text.Json; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +/// +/// Attaching a Google login to a signed-in Heimdall account by explicit link. Bifrost keeps no Heimdall token: each +/// Heimdall call that needs a bearer follows a fresh Heimdall sign-in, and the bearer lives inside that one request. +/// The attempt cookie carries only a purpose, the expected account and a pending link id. +/// +public sealed class HeimdallGoogleLinkTests +{ + private const string Status = "/Membership/Status"; + private const string ErrorRedirect = "/?auth=heimdall-error"; + + private sealed record Step(HttpResponseMessage Response, StartedAttempt Next, string Token); + + private static string Jar(string session, StartedAttempt attempt) => + $"{BifrostCookies.Session}={session}; {attempt.CookieName}={attempt.Cookie}"; + + private static string? Location(HttpResponseMessage response) => response.Headers.Location?.OriginalString; + + /// B: the fresh sign-in of a LinkStart attempt completes; Bifrost asks Heimdall for the Google link. + private static async Task StepB(HeimdallHost host, string session, StartedAttempt start, string account, string? token = null) + { + token ??= host.Token(account); + var code = host.CodeFor(start, account, token); + var before = host.Heimdall.StartBodies.Count; + var response = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = start.Nonce, ["code"] = code }, Jar(session, start)); + return new Step(response, host.AttemptFrom(response, before), token); + } + + /// C: the browser is back from Google with a pending link id; Bifrost starts a fresh sign-in. + private static async Task StepC(HeimdallHost host, string session, StartedAttempt awaiting, string pendingLinkId) + { + var before = host.Heimdall.StartBodies.Count; + var response = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = awaiting.Nonce, ["pendingLinkId"] = pendingLinkId }, Jar(session, awaiting)); + return new Step(response, host.AttemptFrom(response, before), string.Empty); + } + + /// D: the fresh sign-in of a LinkConfirm attempt completes; Bifrost confirms the link. + private static async Task StepD(HeimdallHost host, string session, StartedAttempt confirming, string account, string? token = null) + { + token ??= host.Token(account); + var code = host.CodeFor(confirming, account, token); + var response = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = confirming.Nonce, ["code"] = code }, Jar(session, confirming)); + return new Step(response, confirming, token); + } + + private sealed record Journey(string Account, string Session, StartedAttempt Start, Step B, string PendingLinkId, Step C); + + /// Everything up to the LinkConfirm attempt, waiting for the fresh sign-in's code. + private static async Task ThroughC(HeimdallHost host, string? pendingLinkId = null) + { + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var b = await StepB(host, session, start, account); + var pending = host.Heimdall.RegisterPendingLink(pendingLinkId); + var c = await StepC(host, session, b.Next, pending); + return new Journey(account, session, start, b, pending, c); + } + + private static string Unprotect(HeimdallHost host, string cookie) => + host.Services.GetRequiredService().CreateProtector("Bifrost.Heimdall.Attempt.v1").Unprotect(cookie); + + // ---- 1. the link start carries a fresh bearer ---- + + [Fact] + public async Task The_account_page_starts_an_ordinary_sign_in_and_the_link_start_follows_with_the_fresh_bearer() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var signInStarts = host.Heimdall.StartBodies.Count; + + var start = await host.StartLinkAsync(session); + + Assert.Equal(HttpStatusCode.Redirect, start.Response.StatusCode); + Assert.Equal(signInStarts + 1, host.Heimdall.StartBodies.Count); + using (var body = JsonDocument.Parse(host.Heimdall.StartBodies[^1])) + { + Assert.Equal("sign_in", body.RootElement.GetProperty("mode").GetString()); + } + + Assert.Equal("/v1/oauth/discord/start", host.Heimdall.StartPaths[^1]); + Assert.Null(host.Heimdall.StartAuthorizations[^1]); + Assert.NotNull(start.Cookie); + + var b = await StepB(host, session, start, account); + + Assert.Equal(signInStarts + 2, host.Heimdall.StartBodies.Count); + Assert.Equal("/v1/oauth/google/start", host.Heimdall.StartPaths[^1]); + Assert.Equal("Bearer " + b.Token, host.Heimdall.StartAuthorizations[^1]); + using var link = JsonDocument.Parse(host.Heimdall.StartBodies[^1]); + Assert.Equal("link", link.RootElement.GetProperty("mode").GetString()); + Assert.Equal("bifrost", link.RootElement.GetProperty("appSlug").GetString()); + Assert.Equal("browser_completion", link.RootElement.GetProperty("handoff").GetProperty("kind").GetString()); + Assert.StartsWith("https://localhost/auth/heimdall/complete?attempt=", link.RootElement.GetProperty("returnTo").GetString()); + Assert.NotEqual(start.Nonce, b.Next.Nonce); + Assert.Equal("https://heimdall.test/oauth/google/authorize?state=link-fixture", Location(b.Response)); + // The sign-in session is untouched: the link flow never replaces who is signed in. + Assert.Null(HeimdallHost.CookieValue(b.Response, BifrostCookies.Session)); + Assert.Equal(HttpStatusCode.Redirect, b.Response.StatusCode); + } + + [Fact] + public async Task A_link_start_attempt_without_a_code_asks_Heimdall_for_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var before = host.Heimdall.StartBodies.Count; + + var response = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = start.Nonce }, Jar(session, start)); + + Assert.Equal(before, host.Heimdall.StartBodies.Count); + Assert.Equal(Status + "?link=error", Location(response)); + } + + [Fact] + public async Task A_link_start_attempt_cannot_sign_in_a_session() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var other = HeimdallShapes.NewAccountId(); + + var done = await host.CompleteAsync(start, host.CodeFor(start, other)); + + Assert.False(done.SignedIn); + Assert.Null(await host.Account(other)); + } + + // ---- 2. only a signed-in user, only by POST with antiforgery ---- + + [Fact] + public async Task Without_a_session_a_link_post_starts_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var before = host.Heimdall.StartBodies.Count; + + var start = await host.StartLinkAsync(session: null); + + Assert.Equal(before, host.Heimdall.StartBodies.Count); + Assert.Null(start.Cookie); + Assert.NotEqual(HttpStatusCode.OK, start.Response.StatusCode); + } + + [Fact] + public async Task A_get_never_starts_a_link() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + var before = host.Heimdall.StartBodies.Count; + + var start = await host.StartLinkAsync(session, method: HttpMethod.Get); + + Assert.Equal(before, host.Heimdall.StartBodies.Count); + Assert.Null(start.Cookie); + } + + [Fact] + public async Task A_link_post_without_the_antiforgery_token_starts_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + var before = host.Heimdall.StartBodies.Count; + + var start = await host.StartLinkAsync(session, antiforgery: false); + + Assert.Equal(before, host.Heimdall.StartBodies.Count); + Assert.Null(start.Cookie); + Assert.Equal(HttpStatusCode.BadRequest, start.Response.StatusCode); + } + + // ---- 3. a browser that is not the one that began the link ---- + + [Fact] + public async Task A_foreign_browser_holding_a_pending_link_id_confirms_nothing_and_calls_Heimdall_zero_times() + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + var redeems = host.Heimdall.RedeemCalls; + var starts = host.Heimdall.StartBodies.Count; + + // No cookie at all, and a session of its own; then an unrelated sign-in attempt's cookie. + var strangerAccount = HeimdallShapes.NewAccountId(); + var stranger = await host.SignInSessionAsync(strangerAccount); + redeems = host.Heimdall.RedeemCalls; + starts = host.Heimdall.StartBodies.Count; + var noCookie = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = journey.B.Next.Nonce, ["pendingLinkId"] = journey.PendingLinkId }, + $"{BifrostCookies.Session}={stranger}"); + var unrelated = await host.StartAsync(); + starts = host.Heimdall.StartBodies.Count; + var wrongCookie = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = journey.B.Next.Nonce, ["pendingLinkId"] = journey.PendingLinkId }, + $"{BifrostCookies.Session}={stranger}; {unrelated.CookieName}={unrelated.Cookie}"); + var forgedCode = host.CodeFor(journey.C.Next, strangerAccount); + var confirmWithoutCookie = await host.PostCompleteAsync( + new Dictionary { ["attempt"] = journey.C.Next.Nonce, ["code"] = forgedCode }, + $"{BifrostCookies.Session}={stranger}"); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(redeems, host.Heimdall.RedeemCalls); + Assert.Empty(host.Heimdall.ConfirmBodies); + foreach (var response in new[] { noCookie, wrongCookie, confirmWithoutCookie }) + { + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.DoesNotContain("Bearer", Location(response)); + } + } + + // ---- 4. a different Heimdall account ---- + + [Fact] + public async Task Completion_for_a_different_account_than_the_session_is_refused_with_no_link_call() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var starts = host.Heimdall.StartBodies.Count; + + var b = await StepB(host, session, start, HeimdallShapes.NewAccountId()); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Status + "?link=error", Location(b.Response)); + AssertAttemptCleared(b.Response, start); + } + + [Fact] + public async Task A_session_of_another_account_cannot_continue_the_link() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var intruder = HeimdallShapes.NewAccountId(); + var intruderSession = await host.SignInSessionAsync(intruder); + var starts = host.Heimdall.StartBodies.Count; + var redeems = host.Heimdall.RedeemCalls; + + var b = await StepB(host, intruderSession, start, intruder); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(redeems, host.Heimdall.RedeemCalls); + Assert.Equal(Status + "?link=error", Location(b.Response)); + } + + [Fact] + public async Task A_confirming_sign_in_for_another_account_confirms_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + var other = HeimdallShapes.NewAccountId(); + + var d = await StepD(host, journey.Session, journey.C.Next, other); + + Assert.Empty(host.Heimdall.ConfirmBodies); + Assert.Equal(Status + "?link=error", Location(d.Response)); + } + + private static void AssertAttemptCleared(HttpResponseMessage response, StartedAttempt attempt) + { + var header = HeimdallHost.SetCookieHeader(response, attempt.CookieName!); + Assert.NotNull(header); + Assert.Contains("1970", header); + } + + // ---- 5. outcomes ---- + + [Fact] + public async Task Success_confirms_once_with_the_pending_link_id_and_a_fresh_bearer_and_shows_linked() + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + Assert.Empty(host.Heimdall.ConfirmBodies); + Assert.Equal(HttpStatusCode.Redirect, journey.C.Response.StatusCode); + Assert.Equal("/v1/oauth/discord/start", host.Heimdall.StartPaths[^1]); + Assert.Null(host.Heimdall.StartAuthorizations[^1]); + + var d = await StepD(host, journey.Session, journey.C.Next, journey.Account); + + Assert.Equal(Status + "?link=linked", Location(d.Response)); + Assert.Single(host.Heimdall.ConfirmBodies); + using var body = JsonDocument.Parse(host.Heimdall.ConfirmBodies[0]); + Assert.Equal(journey.PendingLinkId, body.RootElement.GetProperty("pendingLinkId").GetString()); + Assert.Equal("Bearer " + d.Token, host.Heimdall.ConfirmAuthorizations[0]); + Assert.NotEqual("Bearer " + journey.B.Token, host.Heimdall.ConfirmAuthorizations[0]); + AssertAttemptCleared(d.Response, journey.C.Next); + Assert.Null(HeimdallHost.CookieValue(d.Response, BifrostCookies.Session)); + var account = await host.Account(journey.Account); + Assert.Equal(MembershipStatus.Authenticated, account!.Membership!.Status); + } + + [Theory] + [InlineData(HttpStatusCode.Conflict, "identity_linked_elsewhere")] + [InlineData(HttpStatusCode.Gone, "expired")] + [InlineData(HttpStatusCode.Unauthorized, "error")] + [InlineData(HttpStatusCode.InternalServerError, "error")] + public async Task A_refused_confirm_redirects_with_its_own_fixed_outcome(HttpStatusCode status, string outcome) + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + host.Heimdall.ConfirmStatus = status; + + var d = await StepD(host, journey.Session, journey.C.Next, journey.Account); + + Assert.Equal($"{Status}?link={outcome}", Location(d.Response)); + Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines)); + AssertAttemptCleared(d.Response, journey.C.Next); + } + + [Fact] + public async Task The_account_page_words_each_outcome_from_a_closed_set_and_ignores_anything_else() + { + using var host = new HeimdallHost(realSessions: true); + var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId()); + using var client = host.NewClient(); + + async Task Page(string link) + { + using var request = new HttpRequestMessage(HttpMethod.Get, Status + "?link=" + Uri.EscapeDataString(link)); + request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}"); + return await (await client.SendAsync(request)).Content.ReadAsStringAsync(); + } + + Assert.Contains("Google is now linked", await Page("linked")); + Assert.Contains("already linked to a different Heimdall account", await Page("identity_linked_elsewhere")); + Assert.Contains("expired before it was confirmed", await Page("expired")); + Assert.Contains("could not be completed", await Page("error")); + var hostile = await Page(""); + Assert.DoesNotContain("alert(1)", hostile); + Assert.DoesNotContain("Google is now linked", hostile); + } + + // ---- 6. google stays out of sign-in and member access ---- + + [Theory] + [InlineData("/auth/heimdall/google")] + [InlineData("/auth/heimdall/google?access=member")] + [InlineData("/auth/heimdall/Google")] + public async Task Google_is_refused_as_a_sign_in_provider_and_asks_Heimdall_for_nothing(string url) + { + using var host = new HeimdallHost(realSessions: true); + + var start = await host.StartAtAsync(url); + + Assert.Equal(HttpStatusCode.Redirect, start.Response.StatusCode); + Assert.Equal(ErrorRedirect, Location(start.Response)); + Assert.Empty(host.Heimdall.StartBodies); + } + + // ---- 7. nothing secret leaves the request ---- + + [Fact] + public async Task No_heimdall_token_or_pending_link_id_reaches_a_log_a_cookie_or_a_location() + { + using var host = new HeimdallHost(traceHttp: true, realSessions: true); + const string pendingCanary = "PENDING-CANARY-7f3a"; + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var b = await StepB(host, session, start, account); + var pending = host.Heimdall.RegisterPendingLink(pendingCanary); + var c = await StepC(host, session, b.Next, pending); + var d = await StepD(host, session, c.Next, account); + Assert.Equal(Status + "?link=linked", Location(d.Response)); + + var tokens = new[] { b.Token, d.Token } + .SelectMany(token => new[] { token, token.Split('.')[2], token.Split('.')[1] }) + .ToArray(); + var logs = string.Join("\n", host.Logs.Lines); + Assert.Contains("Bearer", string.Join("\n", host.Heimdall.StartAuthorizations.Where(x => x is not null))); + foreach (var secret in tokens.Append(pendingCanary)) + { + Assert.DoesNotContain(secret, logs); + foreach (var step in new[] { start.Response, b.Response, c.Response, d.Response }) + { + Assert.DoesNotContain(secret, Location(step) ?? string.Empty); + } + } + + // Every attempt cookie Bifrost set along the way, unprotected: purpose, account and pending id only. + foreach (var cookie in new[] { start.Cookie, b.Next.Cookie, c.Next.Cookie }) + { + var payload = Unprotect(host, cookie!); + foreach (var token in tokens) + { + Assert.DoesNotContain(token, payload); + } + + Assert.DoesNotContain("eyJ", payload); + } + + Assert.DoesNotContain(pendingCanary, Unprotect(host, start.Cookie!)); + Assert.DoesNotContain(pendingCanary, Unprotect(host, b.Next.Cookie!)); + Assert.Contains(account, Unprotect(host, b.Next.Cookie!)); + } + + // ---- 8. old attempt cookies keep signing in ---- + + [Fact] + public async Task An_attempt_cookie_written_before_purposes_existed_still_completes_a_normal_sign_in() + { + using var host = new HeimdallHost(realSessions: true); + var nonce = "legacy-nonce-" + Guid.NewGuid().ToString("N"); + var protector = host.Services.GetRequiredService().CreateProtector("Bifrost.Heimdall.Attempt.v1"); + var legacy = protector.Protect(JsonSerializer.Serialize(new Dictionary + { + ["Nonce"] = nonce, + ["Provider"] = "discord", + ["RequiresMemberAccess"] = false, + ["ReturnPath"] = "/App/Mill", + ["ExpiresAt"] = host.Clock.Now.AddMinutes(5) + })); + var account = HeimdallShapes.NewAccountId(); + var attempt = new StartedAttempt( + nonce, HeimdallAttemptCookie.SlotNames[0], legacy, $"https://localhost/auth/heimdall/complete?attempt={nonce}", null!); + + var done = await host.CompleteAsync(attempt, host.CodeFor(attempt, account)); + + Assert.True(done.SignedIn); + Assert.Equal("/App/Mill", Location(done.Response)); + Assert.NotNull(await host.Account(account)); + } + + // ---- 9. attempts are single use ---- + + [Fact] + public async Task Replaying_a_consumed_link_start_attempt_does_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var code = host.CodeFor(start, account); + var fields = new Dictionary { ["attempt"] = start.Nonce, ["code"] = code }; + var first = await host.PostCompleteAsync(fields, Jar(session, start)); + Assert.Contains("google", Location(first)); + var starts = host.Heimdall.StartBodies.Count; + + var replay = await host.PostCompleteAsync(fields, Jar(session, start)); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Status + "?link=error", Location(replay)); + } + + [Fact] + public async Task Replaying_a_consumed_confirm_attempt_confirms_nothing_twice() + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + var code = host.CodeFor(journey.C.Next, journey.Account); + var fields = new Dictionary { ["attempt"] = journey.C.Next.Nonce, ["code"] = code }; + var first = await host.PostCompleteAsync(fields, Jar(journey.Session, journey.C.Next)); + Assert.Equal(Status + "?link=linked", Location(first)); + + var replay = await host.PostCompleteAsync(fields, Jar(journey.Session, journey.C.Next)); + + Assert.Single(host.Heimdall.ConfirmBodies); + Assert.Equal(Status + "?link=error", Location(replay)); + } + + [Fact] + public async Task Replaying_a_consumed_await_attempt_links_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + var again = await StepC(host, journey.Session, journey.B.Next, journey.PendingLinkId); + + // The replay can only begin another fresh sign-in; the pending id is spent at Heimdall by the first confirm. + var d = await StepD(host, journey.Session, again.Next, journey.Account); + var replayed = await StepD(host, journey.Session, journey.C.Next, journey.Account); + + Assert.Equal(Status + "?link=linked", Location(d.Response)); + Assert.Equal(Status + "?link=expired", Location(replayed.Response)); + } +} diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index 67dd4fe..a051db0 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -202,6 +202,29 @@ public sealed class FakeHeimdall : HttpMessageHandler public List StartSecrets { get; } = []; + /// The path of every start, so a google link start can be told from a sign-in start. + public List StartPaths { get; } = []; + + /// The Authorization header of every start (null when absent). + public List StartAuthorizations { get; } = []; + + public List ConfirmBodies { get; } = []; + + public List ConfirmAuthorizations { get; } = []; + + /// When set, every links/confirm answers this status with Heimdall's error code for it. + public HttpStatusCode? ConfirmStatus { get; set; } + + private readonly HashSet _pendingLinks = new(StringComparer.Ordinal); + + /// Registers a pending link Heimdall will confirm once for any bearer. + public string RegisterPendingLink(string? id = null) + { + id ??= "pending-" + Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant(); + _pendingLinks.Add(id); + return id; + } + /// The secret a policy start must carry in x-heimdall-app-secret; null means Heimdall asks for none. public string? RequiredAppSecret { get; set; } @@ -255,8 +278,29 @@ public sealed class FakeHeimdall : HttpMessageHandler { var body = await request.Content!.ReadAsStringAsync(cancellationToken); var secret = request.Headers.TryGetValues("x-heimdall-app-secret", out var values) ? values.Single() : null; + var authorization = request.Headers.Authorization?.ToString(); StartBodies.Add(body); StartSecrets.Add(secret); + StartPaths.Add(path); + StartAuthorizations.Add(authorization); + var root = JsonDocument.Parse(body).RootElement; + if (root.TryGetProperty("mode", out var mode) && mode.GetString() == "link") + { + if (authorization is null) + { + return new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("""{"error":"link_session_required"}""", Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent( + """{"authorizationUrl":"https://heimdall.test/oauth/google/authorize?state=link-fixture"}""", Encoding.UTF8, "application/json") + }; + } + var wantsPolicy = JsonDocument.Parse(body).RootElement.TryGetProperty("entitlementPolicy", out _); if (wantsPolicy && RequiredAppSecret is not null && secret != RequiredAppSecret) { @@ -271,6 +315,35 @@ public sealed class FakeHeimdall : HttpMessageHandler return new HttpResponseMessage(StartStatus) { Content = content }; } + if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/links/confirm") + { + var body = await request.Content!.ReadAsStringAsync(cancellationToken); + var authorization = request.Headers.Authorization?.ToString(); + ConfirmBodies.Add(body); + ConfirmAuthorizations.Add(authorization); + static HttpResponseMessage Json(HttpStatusCode status, string json) => + new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + if (authorization is null) + { + return Json(HttpStatusCode.Unauthorized, """{"error":"invalid_link_session"}"""); + } + + if (ConfirmStatus is { } forced) + { + return forced switch + { + HttpStatusCode.Conflict => Json(forced, """{"error":"identity_linked_elsewhere"}"""), + HttpStatusCode.Gone => Json(forced, """{"error":"invalid_or_expired_pending_link"}"""), + _ => Json(forced, """{"error":"invalid_link_session","detail":"HEIMDALL FREE TEXT"}""") + }; + } + + var id = JsonDocument.Parse(body).RootElement.GetProperty("pendingLinkId").GetString()!; + return _pendingLinks.Remove(id) + ? Json(HttpStatusCode.Created, """{"status":"linked","provider":"google","accountId":"a","returnTo":"https://localhost/auth/heimdall/complete"}""") + : Json(HttpStatusCode.Gone, """{"error":"invalid_or_expired_pending_link"}"""); + } + if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/auth-completions/redeem") { RedeemCalls++; @@ -354,6 +427,8 @@ public sealed class HeimdallHost : TestWebApplicationFactory private readonly string _baseUrl; private readonly string _publicBaseUrl; private readonly bool _discordRoles; + private readonly bool _traceHttp; + private readonly bool _realSessions; public TestClock Clock { get; } = new(); @@ -368,8 +443,12 @@ public sealed class HeimdallHost : TestWebApplicationFactory string baseUrl = HeimdallShapes.Issuer, string publicBaseUrl = "https://localhost", bool discordRoles = true, - string appSlug = "bifrost") + string appSlug = "bifrost", + bool traceHttp = false, + bool realSessions = false) { + _traceHttp = traceHttp; + _realSessions = realSessions; _appSecret = appSecret; _appSlug = appSlug; _baseUrl = baseUrl; @@ -400,9 +479,27 @@ public sealed class HeimdallHost : TestWebApplicationFactory configuration.AddInMemoryCollection(new Dictionary { ["Heimdall:DiscordAllowedRoleIds:0"] = "role-1" }); } }); - builder.ConfigureLogging(logging => logging.AddProvider(Logs)); + builder.ConfigureLogging(logging => + { + logging.AddProvider(Logs); + if (_traceHttp) + { + // The framework's HttpClient logging writes request headers at Trace; it must never write a bearer there. + logging.AddFilter("System.Net.Http", LogLevel.Trace); + } + }); builder.ConfigureServices(services => { + if (_realSessions) + { + // The base factory authenticates every request as a test admin; a link needs the session cookie to be the identity. + services.Configure(options => + { + options.DefaultAuthenticateScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme; + }); + } + services.RemoveAll(); services.AddSingleton(Clock); services.AddHttpClient(HeimdallTokenVerifier.ClientName).ConfigurePrimaryHttpMessageHandler(() => Heimdall); @@ -437,7 +534,12 @@ public sealed class HeimdallHost : TestWebApplicationFactory request.Headers.Add("Cookie", cookieHeader); } - var response = await client.SendAsync(request); + return AttemptFrom(await client.SendAsync(request), before); + } + + /// The attempt a response started at Heimdall, if the start count moved since . + public StartedAttempt AttemptFrom(HttpResponseMessage response, int before) + { if (Heimdall.StartBodies.Count == before) { return new StartedAttempt(string.Empty, null, null, string.Empty, response); @@ -489,6 +591,73 @@ public sealed class HeimdallHost : TestWebApplicationFactory return new CompletionResult(response, !string.IsNullOrEmpty(session)); } + /// A browser session: a normal Heimdall sign-in, answered with the session cookie it earned. + public async Task SignInSessionAsync(string accountId, string provider = "discord") + { + var attempt = await StartAsync(provider: provider); + var done = await CompleteAsync(attempt, CodeFor(attempt, accountId)); + return CookieValue(done.Response, BifrostCookies.Session) ?? throw new InvalidOperationException("sign-in set no session"); + } + + /// The raw POST of the complete page, whatever fields and cookies the caller names. + public async Task PostCompleteAsync(IDictionary fields, string? cookieHeader) + { + using var client = NewClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/heimdall/complete") + { + Content = new FormUrlEncodedContent(fields) + }; + if (cookieHeader is not null) + { + request.Headers.Add("Cookie", cookieHeader); + } + + return await client.SendAsync(request); + } + + public const string LinkPostUrl = "/Membership/Status?handler=LinkGoogle"; + + /// + /// The account page's link button as a browser posts it: the page is fetched for its antiforgery token and cookie + /// (when ), then the form is posted with the session. Without a session nothing is fetched. + /// + public async Task StartLinkAsync(string? session, bool antiforgery = true, HttpMethod? method = null) + { + using var client = NewClient(); + var cookies = new List(); + var fields = new Dictionary(); + if (session is not null) + { + cookies.Add($"{BifrostCookies.Session}={session}"); + } + + if (antiforgery && session is not null) + { + using var get = new HttpRequestMessage(HttpMethod.Get, "/Membership/Status"); + get.Headers.Add("Cookie", string.Join("; ", cookies)); + using var page = await client.SendAsync(get); + var html = await page.Content.ReadAsStringAsync(); + var token = System.Text.RegularExpressions.Regex.Match( + html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value; + fields["__RequestVerificationToken"] = token; + cookies.Add($"{BifrostCookies.Antiforgery}={CookieValue(page, BifrostCookies.Antiforgery)}"); + } + + var before = Heimdall.StartBodies.Count; + using var request = new HttpRequestMessage(method ?? HttpMethod.Post, LinkPostUrl); + if (request.Method == HttpMethod.Post) + { + request.Content = new FormUrlEncodedContent(fields); + } + + if (cookies.Count > 0) + { + request.Headers.Add("Cookie", string.Join("; ", cookies)); + } + + return AttemptFrom(await client.SendAsync(request), before); + } + public Task CompleteAsync(StartedAttempt attempt, string code) => CompleteAsync(attempt.Nonce, code, attempt.Cookie, attempt.CookieName); -- 2.47.3 From 4c9937f881c8df24d97c8f8f6b8c8e798907635a Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 15:21:40 +0000 Subject: [PATCH 2/3] Bifrost: explicit Google link by fresh Heimdall sign-in, no stored token The account page's POST starts an ordinary sign-in whose attempt (a purpose, the expected account id, a pending link id; never a token) is only the proof for a link. Each Heimdall call that needs a bearer follows its own fresh sign-in; the bearer lives in that request. Google stays out of AllowedProviders and member access. Authorization is redacted from HttpClient logging. Outcomes redirect to a fixed closed set of wording. Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Heimdall/HeimdallAuthService.cs | 301 +++++++++++++++++- .../Features/Heimdall/HeimdallCompletePage.cs | 10 +- .../Pages/Membership/Status.cshtml | 13 + .../Pages/Membership/Status.cshtml.cs | 48 ++- src/Bifrost.Web/Program.cs | 30 +- 5 files changed, 380 insertions(+), 22 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 076f788..b24b4ab 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -41,6 +41,14 @@ public sealed class HeimdallAuthService( "twitch" }; + /// Whether a provider may sign a person in (and so be asked for at /auth/heimdall/{provider}). Google is identity only: it can be linked, never signed in with. + public static bool IsSignInProvider(string provider) => AllowedProviders.Contains(provider); + + /// The headers HttpClient logging must never write: the app secret and any bearer. + public static bool RedactsHeader(string header) => + string.Equals(header, AppSecretHeader, StringComparison.OrdinalIgnoreCase) + || string.Equals(header, "Authorization", StringComparison.OrdinalIgnoreCase); + private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Bifrost.Heimdall.Attempt.v1"); /// Points the client at Heimdall and, when the app has a secret, identifies Bifrost as the app. @@ -96,7 +104,11 @@ public sealed class HeimdallAuthService( LocalReturnPath returnPath, bool requireMemberAccess, IEnumerable> requestCookies, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + HeimdallAttemptPurpose purpose = HeimdallAttemptPurpose.SignIn, + string? expectedAccountId = null, + string? pendingLinkId = null, + string? replaceSlot = null) { if (!AllowedProviders.Contains(provider)) { @@ -144,11 +156,40 @@ public sealed class HeimdallAuthService( provider, requireMemberAccess, returnPath, - timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime); + timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime, + purpose, + expectedAccountId, + pendingLinkId); return new HeimdallAuthStartResult( - new Uri(payload.AuthorizationUrl, UriKind.Absolute), ChooseSlot(requestCookies), Protect(attempt)); + new Uri(payload.AuthorizationUrl, UriKind.Absolute), replaceSlot ?? ChooseSlot(requestCookies), Protect(attempt)); } + /// The provider a link's fresh sign-in uses: the one the person signed in with, else Discord. + public static string LinkSignInProvider(string? sessionProvider) => + sessionProvider is not null && AllowedProviders.Contains(sessionProvider) ? sessionProvider.ToLowerInvariant() : "discord"; + + /// + /// The account page's link button: an ordinary Heimdall sign-in whose attempt says it is only the proof for a + /// link, and which person it must prove. The session is never replaced by it. + /// + public Task StartLinkAsync( + string sessionAccountId, + string? sessionProvider, + string callbackBaseUrl, + IEnumerable> requestCookies, + CancellationToken cancellationToken) => + StartAsync( + LinkSignInProvider(sessionProvider), + callbackBaseUrl, + LocalReturnPath.Parse(LinkReturnPath), + requireMemberAccess: false, + requestCookies, + cancellationToken, + HeimdallAttemptPurpose.LinkStart, + sessionAccountId); + + public const string LinkReturnPath = "/Membership/Status"; + /// /// The slot the new attempt's cookie takes: the first slot that holds no live attempt, else the one whose /// attempt expires first. Parallel starts carry the same request cookies, so they choose the same slot and @@ -195,15 +236,13 @@ public sealed class HeimdallAuthService( return null; } - public async Task CompleteAsync( - string callbackBaseUrl, - string? nonce, - string? cookieValue, - string? code, - CancellationToken cancellationToken) + /// 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; + + /// 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)> RedeemVerifiedAsync( + string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken) { - var attempt = Unprotect(cookieValue, nonce) - ?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser."); if (string.IsNullOrWhiteSpace(code)) { throw new HeimdallAuthException("Heimdall completion carried no code."); @@ -221,6 +260,24 @@ public sealed class HeimdallAuthService( } var identity = await tokenVerifier.VerifyAsync(redeemed.AccessToken, redeemed.Account?.Id, cancellationToken); + return (identity, redeemed.AccessToken); + } + + public async Task CompleteAsync( + string callbackBaseUrl, + string? nonce, + string? cookieValue, + string? code, + CancellationToken cancellationToken) + { + var attempt = Unprotect(cookieValue, nonce) + ?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser."); + if (attempt.Purpose != HeimdallAttemptPurpose.SignIn) + { + throw new HeimdallAuthException("Heimdall completion is not a sign-in attempt."); + } + + var (identity, _) = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken); if (attempt.RequiresMemberAccess && !identity.HasMemberAccess) { throw new HeimdallAuthException("Heimdall did not grant Bifrost member access."); @@ -236,6 +293,155 @@ public sealed class HeimdallAuthService( attempt.ReturnPath); } + /// + /// One step of a Google link, chosen by the purpose of the attempt this browser holds. The browser's session must be + /// the account the link was begun for, checked before Heimdall is asked anything and again against the identity the + /// fresh sign-in proves. Each step consumes the held attempt: the next step is a new attempt in the same slot, or none. + /// + public async Task ContinueLinkAsync( + string callbackBaseUrl, + string? nonce, + string cookieName, + string? cookieValue, + string? code, + string? pendingLinkId, + string? sessionAccountId, + CancellationToken cancellationToken) + { + var attempt = Unprotect(cookieValue, nonce) + ?? throw new HeimdallAuthException("Heimdall link has no attempt for this browser."); + if (string.IsNullOrEmpty(attempt.ExpectedAccountId) + || !string.Equals(sessionAccountId, attempt.ExpectedAccountId, StringComparison.Ordinal)) + { + throw new HeimdallAuthException("Heimdall link belongs to another session."); + } + + switch (attempt.Purpose) + { + case HeimdallAttemptPurpose.LinkStart: + { + var (_, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); + var authorizationUrl = await StartGoogleLinkAsync(callbackBaseUrl, attempt, bearer, cancellationToken); + return new HeimdallLinkStep(authorizationUrl.Url, cookieName, authorizationUrl.CookieValue, null); + } + + case HeimdallAttemptPurpose.LinkAwaitGoogle: + { + if (string.IsNullOrWhiteSpace(pendingLinkId)) + { + throw new HeimdallAuthException("Heimdall link carried no pending link."); + } + + var next = await StartAsync( + LinkSignInProvider(attempt.Provider), + callbackBaseUrl, + attempt.ReturnPath, + requireMemberAccess: false, + [], + cancellationToken, + HeimdallAttemptPurpose.LinkConfirm, + attempt.ExpectedAccountId, + pendingLinkId, + replaceSlot: cookieName); + return new HeimdallLinkStep(next.AuthorizationUrl, cookieName, next.CookieValue, null); + } + + case HeimdallAttemptPurpose.LinkConfirm: + { + var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken); + var outcome = await ConfirmLinkAsync(attempt.PendingLinkId!, bearer, cancellationToken); + if (outcome == HeimdallLinkOutcome.Linked) + { + // Membership follows the verified identity exactly as at sign-in; the session principal is not replaced. + await UpsertUserAccountAsync(identity, attempt.Provider, grantMemberAccess: false, cancellationToken); + } + + return new HeimdallLinkStep(null, cookieName, null, outcome); + } + + default: + throw new HeimdallAuthException("Heimdall completion is not a link attempt."); + } + } + + /// Redeems a link attempt's code and requires it to prove the account the link was begun for. + private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken)> RedeemOwnerAsync( + string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken) + { + var proven = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken); + if (!string.Equals(proven.Identity.AccountId, attempt.ExpectedAccountId, StringComparison.Ordinal)) + { + throw new HeimdallAuthException("Heimdall link was proven by another account."); + } + + return proven; + } + + private async Task<(Uri Url, string CookieValue)> StartGoogleLinkAsync( + string callbackBaseUrl, HeimdallAttempt attempt, string bearer, CancellationToken cancellationToken) + { + var options = heimdallOptions.Value; + var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); + using var message = new HttpRequestMessage(HttpMethod.Post, "/v1/oauth/google/start") + { + Content = JsonContent.Create(new HeimdallStartRequest( + options.AppSlug, "link", ToAttemptReturnUrl(callbackBaseUrl, nonce), new HeimdallHandoff("browser_completion"), null)) + }; + message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer); + using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new HeimdallAuthException(await RefusedStartMessageAsync(response, cancellationToken)); + } + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken); + if (payload is null || string.IsNullOrWhiteSpace(payload.AuthorizationUrl)) + { + throw new HeimdallAuthException("Heimdall link start returned no authorization URL."); + } + + var awaiting = new HeimdallAttempt( + nonce, + attempt.Provider, + false, + attempt.ReturnPath, + timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime, + HeimdallAttemptPurpose.LinkAwaitGoogle, + attempt.ExpectedAccountId, + null); + return (new Uri(payload.AuthorizationUrl, UriKind.Absolute), Protect(awaiting)); + } + + private async Task ConfirmLinkAsync(string pendingLinkId, string bearer, CancellationToken cancellationToken) + { + try + { + using var message = new HttpRequestMessage( + HttpMethod.Post, $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/links/confirm") + { + Content = JsonContent.Create(new HeimdallConfirmLinkRequest(pendingLinkId)) + }; + message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer); + using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + switch (response.StatusCode) + { + case HttpStatusCode.Created: + var linked = await response.Content.ReadFromJsonAsync(cancellationToken); + return linked?.Status == "linked" ? HeimdallLinkOutcome.Linked : HeimdallLinkOutcome.Error; + case HttpStatusCode.Conflict: + return HeimdallLinkOutcome.IdentityLinkedElsewhere; + case HttpStatusCode.Gone: + return HeimdallLinkOutcome.Expired; + default: + return HeimdallLinkOutcome.Error; + } + } + catch (Exception error) when (error is HttpRequestException or JsonException or NotSupportedException && !cancellationToken.IsCancellationRequested) + { + return HeimdallLinkOutcome.Error; + } + } + private async Task RedeemAsync(string code, CancellationToken cancellationToken) { try @@ -260,7 +466,8 @@ public sealed class HeimdallAuthService( private string Protect(HeimdallAttempt attempt) => _protector.Protect(JsonSerializer.Serialize(new AttemptWire( - attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt))); + attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt, + attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId))); /// The live attempt a cookie value protects when it belongs to this nonce, else null. private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) => @@ -277,13 +484,14 @@ public sealed class HeimdallAuthService( try { var wire = JsonSerializer.Deserialize(_protector.Unprotect(cookieValue)); - if (wire is null || wire.ExpiresAt <= timeProvider.GetUtcNow()) + if (wire is null || wire.ExpiresAt <= timeProvider.GetUtcNow() || !Enum.IsDefined(wire.Purpose)) { return null; } return new HeimdallAttempt( - wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt); + wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt, + wire.Purpose, wire.ExpectedAccountId, wire.PendingLinkId); } catch (Exception error) when (error is CryptographicException or JsonException or FormatException) { @@ -296,7 +504,10 @@ public sealed class HeimdallAuthService( string Provider, bool RequiresMemberAccess, string ReturnPath, - DateTimeOffset ExpiresAt); + DateTimeOffset ExpiresAt, + HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, + string? ExpectedAccountId = null, + string? PendingLinkId = null); private async Task UpsertUserAccountAsync( HeimdallVerifiedIdentity identity, @@ -448,7 +659,65 @@ public sealed record HeimdallAttempt( string Provider, bool RequiresMemberAccess, LocalReturnPath ReturnPath, - DateTimeOffset ExpiresAt); + DateTimeOffset ExpiresAt, + HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn, + string? ExpectedAccountId = null, + string? PendingLinkId = null); + +/// +/// What a held attempt is for. SignIn is the default, so a cookie written before purposes existed reads as a sign-in. +/// The link purposes are the fresh sign-ins and the Google round trip of an explicit link; they never sign anyone in. +/// +public enum HeimdallAttemptPurpose +{ + SignIn = 0, + LinkStart = 1, + LinkAwaitGoogle = 2, + LinkConfirm = 3 +} + +public enum HeimdallLinkOutcome +{ + Linked, + IdentityLinkedElsewhere, + Expired, + Error +} + +/// +/// Where a link step sends the browser: to with a new attempt cookie in the held slot, or, when the +/// link is finished, back to the account page with . +/// +public static class HeimdallLinkOutcomes +{ + public static string Redirect(HeimdallLinkOutcome outcome) => HeimdallAuthService.LinkReturnPath + "?link=" + Code(outcome); + + public static string Code(HeimdallLinkOutcome outcome) => outcome switch + { + HeimdallLinkOutcome.Linked => "linked", + HeimdallLinkOutcome.IdentityLinkedElsewhere => "identity_linked_elsewhere", + HeimdallLinkOutcome.Expired => "expired", + _ => "error" + }; + + /// The words for an outcome code: a closed set, never Heimdall's text. Anything else gets none. + public static string? Wording(string? code) => code switch + { + "linked" => "Google is now linked to your Heimdall account.", + "identity_linked_elsewhere" => "That Google identity is already linked to a different Heimdall account. Nothing was linked.", + "expired" => "The Google link expired before it was confirmed. Nothing was linked; start the link again.", + "error" => "The Google link could not be completed. Nothing was linked.", + _ => null + }; +} + +public sealed record HeimdallLinkStep(Uri? Next, string CookieName, string? CookieValue, HeimdallLinkOutcome? Outcome); + +public sealed record HeimdallConfirmLinkRequest( + [property: JsonPropertyName("pendingLinkId")] string PendingLinkId); + +public sealed record HeimdallConfirmLinkResponse( + [property: JsonPropertyName("status")] string? Status); public sealed record HeimdallStartRequest( [property: JsonPropertyName("appSlug")] string AppSlug, diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs index 662fb16..b7d67b2 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs @@ -3,7 +3,8 @@ namespace Bifrost.Web.Features.Heimdall; /// /// The page Heimdall's "Return to the app" lands on. The completion code arrives in the URL fragment, which /// the server never sees, so the page's script reads it, drops it from the address bar, and posts it with -/// the attempt to the same path. The page is static: it never contains a code. +/// the attempt to the same path. A Google link returns a pending link id the same way instead of a code. The page +/// is static: it never contains a code or an id. /// public static class HeimdallCompletePage { @@ -22,21 +23,24 @@ public static class HeimdallCompletePage
+
diff --git a/src/Bifrost.Web/Pages/Membership/Status.cshtml b/src/Bifrost.Web/Pages/Membership/Status.cshtml index 2c1ab8f..8f683a4 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml @@ -24,6 +24,19 @@

@Model.StatusMessage

+ @if (Model.LinkMessage is not null) + { +
@Model.LinkMessage
+ } + + @if (Model.CanLinkGoogle) + { +
+ + You will sign in to Heimdall again, then to Google. Google links your account; it does not sign you in. +
+ } + @if (Model.Invitation is not null) {
diff --git a/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs b/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs index d579dd3..13b47ca 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs @@ -1,17 +1,62 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; +using System.Security.Claims; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace Bifrost.Web.Pages.Membership; [Authorize] public sealed class StatusModel( ICurrentBifrostActorAccessor actorAccessor, - BifrostDbContext dbContext) : PageModel + BifrostDbContext dbContext, + HeimdallAuthService heimdallAuthService, + IOptions hostOptions, + ILogger logger) : PageModel { + /// The wording of the link outcome the page was sent back with: a closed set, null for anything else. + public string? LinkMessage { get; private set; } + + /// Whether this session belongs to a Heimdall account a Google login can be attached to. + public bool CanLinkGoogle => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 }; + + /// + /// Begins an explicit Google link: an ordinary Heimdall sign-in for the account already signed in here, kept as + /// an attempt that is only the proof for the link. POST with antiforgery only; nothing is started without a session. + /// + public async Task OnPostLinkGoogleAsync(CancellationToken cancellationToken) + { + var accountId = User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId); + if (string.IsNullOrEmpty(accountId)) + { + return RedirectToPage(null, new { link = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) }); + } + + try + { + var start = await heimdallAuthService.StartLinkAsync( + accountId, + User.FindFirstValue(BifrostClaimTypes.AuthProvider), + hostOptions.Value.PublicBaseUrl, + Request.Cookies, + cancellationToken); + Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options()); + return Redirect(start.AuthorizationUrl.ToString()); + } + catch (HeimdallAuthException error) + { + logger.LogWarning("Heimdall link start refused: {Reason}", error.Message); + return RedirectToPage(null, new { link = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) }); + } + } + public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous; public MembershipInvitation? Invitation { get; private set; } @@ -45,6 +90,7 @@ public sealed class StatusModel( public async Task OnGetAsync(CancellationToken cancellationToken) { Actor = await actorAccessor.GetAsync(cancellationToken); + LinkMessage = HeimdallLinkOutcomes.Wording(Request.Query["link"].ToString()); if (Actor.UserAccount is null) { diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index be3df54..ef92be8 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -152,7 +152,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddHttpClient((serviceProvider, client) => HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) - .RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase)); + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); builder.Services.AddSingleton(); builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); @@ -826,8 +826,32 @@ app.MapPost("/auth/heimdall/complete", async ( var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken); var attempt = form?["attempt"].ToString(); var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt); + var linking = held is not null + && heimdallAuthService.PurposeOf(held.Value.Value, attempt) is not (null or HeimdallAttemptPurpose.SignIn); try { + if (linking) + { + // A link step never signs anyone in: the session in this browser is the one being linked. + var step = await heimdallAuthService.ContinueLinkAsync( + hostOptions.Value.PublicBaseUrl, + attempt, + held!.Value.Name, + held.Value.Value, + form?["code"].ToString(), + form?["pendingLinkId"].ToString(), + httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId), + cancellationToken); + if (step.Next is not null) + { + httpContext.Response.Cookies.Append(step.CookieName, step.CookieValue!, HeimdallAttemptCookie.Options()); + return Results.Redirect(step.Next.ToString()); + } + + httpContext.Response.Cookies.Delete(step.CookieName, HeimdallAttemptCookie.DeleteOptions()); + return Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(step.Outcome!.Value)); + } + var signIn = await heimdallAuthService.CompleteAsync( hostOptions.Value.PublicBaseUrl, attempt, @@ -846,7 +870,9 @@ app.MapPost("/auth/heimdall/complete", async ( httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions()); } - return Results.Redirect(HeimdallAuthService.ErrorRedirect); + return linking + ? Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(HeimdallLinkOutcome.Error)) + : Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); -- 2.47.3 From 1fbe16eb73fda97f13c50e11e500379a099955c2 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 15:46:34 +0000 Subject: [PATCH 3/3] Complete page pin: the form also carries pendingLinkId Co-Authored-By: Claude Sonnet 5.5 --- tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs index 9f91425..c92d99c 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -1510,7 +1510,7 @@ public sealed class HeimdallCompletionTests var inputs = Regex.Matches(form.Groups[2].Value, @"]*)>"); Assert.All(inputs, x => Assert.Contains("type=\"hidden\"", x.Groups[1].Value)); var names = inputs.Select(x => Regex.Match(x.Groups[1].Value, @"\bname=""([^""]*)""").Groups[1].Value).ToArray(); - Assert.Equal(new[] { "attempt", "code" }, names); + Assert.Equal(new[] { "attempt", "code", "pendingLinkId" }, names); // The page's own field names and action, filled as its script fills them, complete a sign-in. var attempt = await host.StartAsync(); -- 2.47.3