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)
+ {
+
+ }
+
@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();
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/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();
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);