Link a Google login to a signed-in member, explicit link only; Bifrost stores no Heimdall token #81
10 changed files with 1076 additions and 29 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
]),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
493
tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs
Normal file
493
tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue