Merge pull request 'Google link: pin the four test gaps, one slot owner, drop IsSignInProvider' (#82) from heimdall/google-link-test-gaps into main

This commit is contained in:
Heimdall 2026-11-12 17:02:18 +00:00
commit 920ecb1fbe
3 changed files with 127 additions and 5 deletions

View file

@ -41,9 +41,6 @@ public sealed class HeimdallAuthService(
"twitch"
};
/// <summary>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.</summary>
public static bool IsSignInProvider(string provider) => AllowedProviders.Contains(provider);
/// <summary>The headers HttpClient logging must never write: the app secret and any bearer.</summary>
public static bool RedactsHeader(string header) =>
string.Equals(header, AppSecretHeader, StringComparison.OrdinalIgnoreCase)
@ -343,7 +340,7 @@ public sealed class HeimdallAuthService(
attempt.ExpectedAccountId,
pendingLinkId,
replaceSlot: cookieName);
return new HeimdallLinkStep(next.AuthorizationUrl, cookieName, next.CookieValue, null);
return new HeimdallLinkStep(next.AuthorizationUrl, next.CookieName, next.CookieValue, null);
}
case HeimdallAttemptPurpose.LinkConfirm:

View file

@ -490,4 +490,115 @@ public sealed class HeimdallGoogleLinkTests
Assert.Equal(Status + "?link=linked", Location(d.Response));
Assert.Equal(Status + "?link=expired", Location(replayed.Response));
}
// ---- 10. pins for guards the first pass left unproven ----
[Fact]
public async Task A_link_continues_in_its_own_slot_and_leaves_a_parallel_sign_in_attempt_alive()
{
using var host = new HeimdallHost(realSessions: true);
var account = HeimdallShapes.NewAccountId();
var session = await host.SignInSessionAsync(account);
var parallel = await host.StartAsync();
var parallelCookie = $"{parallel.CookieName}={parallel.Cookie}";
var start = await host.StartLinkAsync(session, extraCookies: parallelCookie);
// The parallel attempt holds slot 0, so the link lives in the other slot.
Assert.Equal(HeimdallAttemptCookie.SlotNames[0], parallel.CookieName);
Assert.Equal(HeimdallAttemptCookie.SlotNames[1], start.CookieName);
var b = await StepB(host, session, start, account);
var awaiting = new Step(b.Response, b.Next, b.Token);
var pending = host.Heimdall.RegisterPendingLink();
var c = await StepC(host, session, awaiting.Next, pending);
var d = await StepD(host, session, c.Next, account);
// Every continuation wrote the link's own slot and none touched the parallel attempt's slot.
foreach (var response in new[] { b.Response, c.Response })
{
Assert.NotNull(HeimdallHost.SetCookieHeader(response, start.CookieName!));
Assert.Null(HeimdallHost.SetCookieHeader(response, parallel.CookieName!));
}
Assert.Equal(start.CookieName, b.Next.CookieName);
Assert.Equal(start.CookieName, c.Next.CookieName);
Assert.Equal(Status + "?link=linked", Location(d.Response));
Assert.Null(HeimdallHost.SetCookieHeader(d.Response, parallel.CookieName!));
var signIn = await host.CompleteWithHeaderAsync(
parallel.Nonce, host.CodeFor(parallel, HeimdallShapes.NewAccountId()), parallelCookie);
Assert.True(signIn.SignedIn);
}
[Theory]
[InlineData("9")]
[InlineData("-1")]
[InlineData("4")]
public async Task An_attempt_cookie_with_an_undefined_purpose_is_no_attempt_and_asks_Heimdall_for_nothing(string purpose)
{
using var host = new HeimdallHost(realSessions: true);
var account = HeimdallShapes.NewAccountId();
var session = await host.SignInSessionAsync(account);
var nonce = "crafted-" + Guid.NewGuid().ToString("N");
var protector = host.Services.GetRequiredService<IDataProtectionProvider>().CreateProtector("Bifrost.Heimdall.Attempt.v1");
var crafted = protector.Protect(
$$"""{"Nonce":"{{nonce}}","Provider":"discord","RequiresMemberAccess":false,"ReturnPath":"/App/Mill","ExpiresAt":"{{host.Clock.Now.AddMinutes(5):O}}","Purpose":{{purpose}},"ExpectedAccountId":"{{account}}","PendingLinkId":"p"}""");
var attempt = new StartedAttempt(
nonce, HeimdallAttemptCookie.SlotNames[0], crafted, $"https://localhost/auth/heimdall/complete?attempt={nonce}", null!);
var starts = host.Heimdall.StartBodies.Count;
var redeems = host.Heimdall.RedeemCalls;
var response = await host.PostCompleteAsync(
new Dictionary<string, string>
{
["attempt"] = nonce, ["code"] = host.CodeFor(attempt, account), ["pendingLinkId"] = "p"
},
Jar(session, attempt));
Assert.Equal(ErrorRedirect, Location(response));
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
Assert.Equal(redeems, host.Heimdall.RedeemCalls);
Assert.Empty(host.Heimdall.ConfirmBodies);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task An_await_step_without_a_pending_link_id_is_refused_before_Heimdall_hears_of_it(string pendingLinkId)
{
using var host = new HeimdallHost(realSessions: true);
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 starts = host.Heimdall.StartBodies.Count;
var redeems = host.Heimdall.RedeemCalls;
var c = await StepC(host, session, b.Next, pendingLinkId);
Assert.Equal(Status + "?link=error", Location(c.Response));
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
Assert.Equal(redeems, host.Heimdall.RedeemCalls);
Assert.Empty(host.Heimdall.ConfirmBodies);
AssertAttemptCleared(c.Response, b.Next);
}
[Theory]
[InlineData("")]
[InlineData("{}")]
[InlineData("""{"status":"pending"}""")]
[InlineData("""{"status":"Linked"}""")]
[InlineData("not json")]
public async Task A_201_confirm_that_does_not_say_linked_is_not_shown_as_linked(string body)
{
using var host = new HeimdallHost(realSessions: true);
var journey = await ThroughC(host);
host.Heimdall.ConfirmStatus = HttpStatusCode.Created;
host.Heimdall.ConfirmBody = body;
var d = await StepD(host, journey.Session, journey.C.Next, journey.Account);
Assert.Equal(Status + "?link=error", Location(d.Response));
Assert.Single(host.Heimdall.ConfirmBodies);
AssertAttemptCleared(d.Response, journey.C.Next);
}
}

View file

@ -215,6 +215,9 @@ public sealed class FakeHeimdall : HttpMessageHandler
/// <summary>When set, every links/confirm answers this status with Heimdall's error code for it.</summary>
public HttpStatusCode? ConfirmStatus { get; set; }
/// <summary>When set with <see cref="ConfirmStatus"/>, the body of that answer verbatim (empty string included).</summary>
public string? ConfirmBody { get; set; }
private readonly HashSet<string> _pendingLinks = new(StringComparer.Ordinal);
/// <summary>Registers a pending link Heimdall will confirm once for any bearer.</summary>
@ -330,6 +333,11 @@ public sealed class FakeHeimdall : HttpMessageHandler
if (ConfirmStatus is { } forced)
{
if (ConfirmBody is not null)
{
return Json(forced, ConfirmBody);
}
return forced switch
{
HttpStatusCode.Conflict => Json(forced, """{"error":"identity_linked_elsewhere"}"""),
@ -621,7 +629,8 @@ public sealed class HeimdallHost : TestWebApplicationFactory
/// The account page's link button as a browser posts it: the page is fetched for its antiforgery token and cookie
/// (when <paramref name="antiforgery"/>), then the form is posted with the session. Without a session nothing is fetched.
/// </summary>
public async Task<StartedAttempt> StartLinkAsync(string? session, bool antiforgery = true, HttpMethod? method = null)
public async Task<StartedAttempt> StartLinkAsync(
string? session, bool antiforgery = true, HttpMethod? method = null, string? extraCookies = null)
{
using var client = NewClient();
var cookies = new List<string>();
@ -643,6 +652,11 @@ public sealed class HeimdallHost : TestWebApplicationFactory
cookies.Add($"{BifrostCookies.Antiforgery}={CookieValue(page, BifrostCookies.Antiforgery)}");
}
if (extraCookies is not null)
{
cookies.Add(extraCookies);
}
var before = Heimdall.StartBodies.Count;
using var request = new HttpRequestMessage(method ?? HttpMethod.Post, LinkPostUrl);
if (request.Method == HttpMethod.Post)