Link a Google login to a signed-in member, explicit link only; Bifrost stores no Heimdall token #81

Merged
heimdall merged 3 commits from heimdall/google-link into main 2026-11-12 16:20:50 +00:00
10 changed files with 1076 additions and 29 deletions

View file

@ -41,6 +41,14 @@ 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)
|| string.Equals(header, "Authorization", StringComparison.OrdinalIgnoreCase);
private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Bifrost.Heimdall.Attempt.v1");
/// <summary>Points the client at Heimdall and, when the app has a secret, identifies Bifrost as the app.</summary>
@ -96,7 +104,11 @@ public sealed class HeimdallAuthService(
LocalReturnPath returnPath,
bool requireMemberAccess,
IEnumerable<KeyValuePair<string, string>> 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));
}
/// <summary>The provider a link's fresh sign-in uses: the one the person signed in with, else Discord.</summary>
public static string LinkSignInProvider(string? sessionProvider) =>
sessionProvider is not null && AllowedProviders.Contains(sessionProvider) ? sessionProvider.ToLowerInvariant() : "discord";
/// <summary>
/// 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.
/// </summary>
public Task<HeimdallAuthStartResult> StartLinkAsync(
string sessionAccountId,
string? sessionProvider,
string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies,
CancellationToken cancellationToken) =>
StartAsync(
LinkSignInProvider(sessionProvider),
callbackBaseUrl,
LocalReturnPath.Parse(LinkReturnPath),
requireMemberAccess: false,
requestCookies,
cancellationToken,
HeimdallAttemptPurpose.LinkStart,
sessionAccountId);
public const string LinkReturnPath = "/Membership/Status";
/// <summary>
/// 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<HeimdallCompletedSignIn> CompleteAsync(
string callbackBaseUrl,
string? nonce,
string? cookieValue,
string? code,
CancellationToken cancellationToken)
/// <summary>What the attempt this browser holds for the nonce is for, or null when it holds none.</summary>
public HeimdallAttemptPurpose? PurposeOf(string? cookieValue, string? nonce) => Unprotect(cookieValue, nonce)?.Purpose;
/// <summary>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.</summary>
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<HeimdallCompletedSignIn> 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);
}
/// <summary>
/// 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.
/// </summary>
public async Task<HeimdallLinkStep> 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.");
}
}
/// <summary>Redeems a link attempt's code and requires it to prove the account the link was begun for.</summary>
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<HeimdallStartResponse>(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<HeimdallLinkOutcome> 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<HeimdallConfirmLinkResponse>(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<HeimdallRedeemResponse> 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)));
/// <summary>The live attempt a cookie value protects when it belongs to this nonce, else null.</summary>
private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) =>
@ -277,13 +484,14 @@ public sealed class HeimdallAuthService(
try
{
var wire = JsonSerializer.Deserialize<AttemptWire>(_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<UserAccount> 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);
/// <summary>
/// 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.
/// </summary>
public enum HeimdallAttemptPurpose
{
SignIn = 0,
LinkStart = 1,
LinkAwaitGoogle = 2,
LinkConfirm = 3
}
public enum HeimdallLinkOutcome
{
Linked,
IdentityLinkedElsewhere,
Expired,
Error
}
/// <summary>
/// Where a link step sends the browser: to <see cref="Next"/> with a new attempt cookie in the held slot, or, when the
/// link is finished, back to the account page with <see cref="Outcome"/>.
/// </summary>
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"
};
/// <summary>The words for an outcome code: a closed set, never Heimdall's text. Anything else gets none.</summary>
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,

View file

@ -3,7 +3,8 @@ namespace Bifrost.Web.Features.Heimdall;
/// <summary>
/// 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.
/// </summary>
public static class HeimdallCompletePage
{
@ -22,21 +23,24 @@ public static class HeimdallCompletePage
<form id="complete" method="post" action="/auth/heimdall/complete">
<input type="hidden" name="attempt" />
<input type="hidden" name="code" />
<input type="hidden" name="pendingLinkId" />
</form>
<script>
(function () {
var fragment = new URLSearchParams(location.hash.slice(1));
var attempt = new URLSearchParams(location.search).get("attempt");
var code = fragment.get("heimdall_completion_code");
var pendingLinkId = fragment.get("heimdall_pending_link_id");
var failed = fragment.get("heimdall_status") === "error";
history.replaceState(null, "", location.pathname + location.search);
if (failed || !code || !attempt) {
if (failed || !(code || pendingLinkId) || !attempt) {
location.replace("/?auth=heimdall-error");
return;
}
var form = document.getElementById("complete");
form.elements.attempt.value = attempt;
form.elements.code.value = code;
form.elements.code.value = code || "";
form.elements.pendingLinkId.value = pendingLinkId || "";
form.submit();
})();
</script>

View file

@ -24,6 +24,19 @@
<p class="muted">@Model.StatusMessage</p>
</div>
@if (Model.LinkMessage is not null)
{
<div class="callout" role="status">@Model.LinkMessage</div>
}
@if (Model.CanLinkGoogle)
{
<form method="post" asp-page-handler="LinkGoogle" class="panel-actions">
<button class="button secondary" type="submit">Link a Google login</button>
<span class="muted">You will sign in to Heimdall again, then to Google. Google links your account; it does not sign you in.</span>
</form>
}
@if (Model.Invitation is not null)
{
<div class="callout success">

View file

@ -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<BifrostHostOptions> hostOptions,
ILogger<StatusModel> logger) : PageModel
{
/// <summary>The wording of the link outcome the page was sent back with: a closed set, null for anything else.</summary>
public string? LinkMessage { get; private set; }
/// <summary>Whether this session belongs to a Heimdall account a Google login can be attached to.</summary>
public bool CanLinkGoogle => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 };
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> 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)
{

View file

@ -152,7 +152,7 @@ builder.Services.AddSingleton<StartupConfigurationValidator>();
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
.RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase));
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
builder.Services.AddSingleton<HeimdallTokenVerifier>();
builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) =>
HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().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();

View file

@ -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", ""),
]),
);

View file

@ -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<Dictionary<string, Run>> 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")]

View file

@ -1510,7 +1510,7 @@ public sealed class HeimdallCompletionTests
var inputs = Regex.Matches(form.Groups[2].Value, @"<input\b([^>]*)>");
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();

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>B: the fresh sign-in of a LinkStart attempt completes; Bifrost asks Heimdall for the Google link.</summary>
private static async Task<Step> 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<string, string> { ["attempt"] = start.Nonce, ["code"] = code }, Jar(session, start));
return new Step(response, host.AttemptFrom(response, before), token);
}
/// <summary>C: the browser is back from Google with a pending link id; Bifrost starts a fresh sign-in.</summary>
private static async Task<Step> StepC(HeimdallHost host, string session, StartedAttempt awaiting, string pendingLinkId)
{
var before = host.Heimdall.StartBodies.Count;
var response = await host.PostCompleteAsync(
new Dictionary<string, string> { ["attempt"] = awaiting.Nonce, ["pendingLinkId"] = pendingLinkId }, Jar(session, awaiting));
return new Step(response, host.AttemptFrom(response, before), string.Empty);
}
/// <summary>D: the fresh sign-in of a LinkConfirm attempt completes; Bifrost confirms the link.</summary>
private static async Task<Step> 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<string, string> { ["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);
/// <summary>Everything up to the LinkConfirm attempt, waiting for the fresh sign-in's code.</summary>
private static async Task<Journey> 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<IDataProtectionProvider>().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<string, string> { ["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<string, string> { ["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<string, string> { ["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<string, string> { ["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<string> 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("<script>alert(1)</script>");
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<IDataProtectionProvider>().CreateProtector("Bifrost.Heimdall.Attempt.v1");
var legacy = protector.Protect(JsonSerializer.Serialize(new Dictionary<string, object>
{
["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<string, string> { ["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<string, string> { ["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));
}
}

View file

@ -202,6 +202,29 @@ public sealed class FakeHeimdall : HttpMessageHandler
public List<string?> StartSecrets { get; } = [];
/// <summary>The path of every start, so a google link start can be told from a sign-in start.</summary>
public List<string> StartPaths { get; } = [];
/// <summary>The Authorization header of every start (null when absent).</summary>
public List<string?> StartAuthorizations { get; } = [];
public List<string> ConfirmBodies { get; } = [];
public List<string?> ConfirmAuthorizations { get; } = [];
/// <summary>When set, every links/confirm answers this status with Heimdall's error code for it.</summary>
public HttpStatusCode? ConfirmStatus { get; set; }
private readonly HashSet<string> _pendingLinks = new(StringComparer.Ordinal);
/// <summary>Registers a pending link Heimdall will confirm once for any bearer.</summary>
public string RegisterPendingLink(string? id = null)
{
id ??= "pending-" + Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();
_pendingLinks.Add(id);
return id;
}
/// <summary>The secret a policy start must carry in x-heimdall-app-secret; null means Heimdall asks for none.</summary>
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<string, string?> { ["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<Microsoft.AspNetCore.Authentication.AuthenticationOptions>(options =>
{
options.DefaultAuthenticateScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme;
});
}
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(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);
}
/// <summary>The attempt a response started at Heimdall, if the start count moved since <paramref name="before"/>.</summary>
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));
}
/// <summary>A browser session: a normal Heimdall sign-in, answered with the session cookie it earned.</summary>
public async Task<string> 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");
}
/// <summary>The raw POST of the complete page, whatever fields and cookies the caller names.</summary>
public async Task<HttpResponseMessage> PostCompleteAsync(IDictionary<string, string> 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";
/// <summary>
/// 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)
{
using var client = NewClient();
var cookies = new List<string>();
var fields = new Dictionary<string, string>();
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<CompletionResult> CompleteAsync(StartedAttempt attempt, string code) =>
CompleteAsync(attempt.Nonce, code, attempt.Cookie, attempt.CookieName);