Bifrost/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs
Heimdall cfd513655e Passkeys: spend challenges, tickets and permits only after the caller's preconditions; re-check revocation at completion; loud RP at boot; honest page when unavailable; label by text element
Soul fixes on PR #85: junk from a session thief no longer burns the member's ticket, challenge or verification permits; a passkey revoked between
assertion and completion does not unlink; PasskeyRelyingParty is resolved at startup and rejects an ExpectedHost with a port; with a strong passkey and an
unavailable RP the page shows a message instead of a dead form; Label() cuts by text element and drops format characters. Tests for each, the user-handle
owner callback, the account-scoped lookup (allow list stripped through the ceremony store) and the ticket cookie SameSite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 21:47:45 +00:00

1153 lines
55 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Passkeys;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Heimdall;
/// <summary>
/// Heimdall sign-in. Start issues an attempt that lives only in a Data Protection-protected cookie in the
/// browser that began it. The browser that Heimdall authenticated presents the completion code from its
/// URL fragment; Bifrost redeems it at Heimdall itself and signs in only when that browser holds the
/// attempt's cookie and the redeemed return target names the same attempt. The Linked accounts page's steps (list,
/// link, unlink) are the same machine with other attempt purposes: each is a fresh Heimdall sign-in whose bearer is
/// used inside that one request and dropped.
/// </summary>
public sealed class HeimdallAuthService(
HttpClient httpClient,
HeimdallTokenVerifier tokenVerifier,
BifrostDbContext dbContext,
BifrostIdentityService bifrostIdentityService,
IOptions<HeimdallOptions> heimdallOptions,
IDataProtectionProvider dataProtectionProvider,
TimeProvider timeProvider,
HeimdallMemberClient memberClient,
LinkedAccountsSnapshots snapshots,
PasskeyCeremonies ceremonies,
PasskeyService passkeys)
{
public const string ErrorRedirect = "/?auth=heimdall-error";
public const string AppSecretHeader = "x-heimdall-app-secret";
private static readonly HashSet<string> AllowedProviders = new(StringComparer.OrdinalIgnoreCase)
{
"discord",
"patreon",
"twitch"
};
/// <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>
public static void ConfigureClient(HttpClient client, HeimdallOptions options)
{
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/'));
if (!string.IsNullOrWhiteSpace(options.AppSecret))
{
client.DefaultRequestHeaders.Add(AppSecretHeader, options.AppSecret);
}
}
/// <summary>
/// Names the HTTP status and, when the body carries one, Heimdall's error code (a short snake_case token only,
/// see <see cref="HeimdallRefusal"/>), so nothing else from the body reaches a log.
/// </summary>
private static async Task<string> RefusedStartMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var detail = $"HTTP {(int)response.StatusCode}";
if (await HeimdallRefusal.CodeAsync(response, cancellationToken) is { } code)
{
detail += ", " + code;
}
return $"Heimdall rejected the auth start ({detail}).";
}
public async Task<HeimdallAuthStartResult> StartAsync(
string provider,
string callbackBaseUrl,
LocalReturnPath returnPath,
bool requireMemberAccess,
IEnumerable<KeyValuePair<string, string>> requestCookies,
CancellationToken cancellationToken,
HeimdallAttemptPurpose purpose = HeimdallAttemptPurpose.SignIn,
string? expectedAccountId = null,
string? pendingLinkId = null,
string? replaceSlot = null,
bool stepUp = false,
string? target = null,
string? proof = null)
{
// The public sign-in routes offer AllowedProviders; the proofs of the Linked accounts page may also use Google
// (a step-up proof only Google can give a bifrost sign-in, an ordinary one for an account that holds Google).
if (!(purpose == HeimdallAttemptPurpose.SignIn ? AllowedProviders : ProofProviders).Contains(provider))
{
throw new HeimdallAuthException("Unsupported Heimdall provider.");
}
var options = heimdallOptions.Value;
if (!options.IsConfigured)
{
throw new HeimdallAuthException("Heimdall is not configured.");
}
var entitlementPolicy = requireMemberAccess
? BuildMemberAccessPolicy(provider, options)
: null;
var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
var request = new HeimdallStartRequest(
options.AppSlug,
"sign_in",
ToAttemptReturnUrl(callbackBaseUrl, nonce),
new HeimdallHandoff("browser_completion"),
entitlementPolicy,
stepUp ? true : null);
// Headers only: a refusal body is read through RefusedStartMessageAsync's own 4 KiB bound, not buffered whole.
using var startMessage = new HttpRequestMessage(HttpMethod.Post, $"/v1/oauth/{provider}/start")
{
Content = JsonContent.Create(request)
};
using var response = await SendStartAsync(startMessage, 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 auth start returned no authorization URL.");
}
var attempt = new HeimdallAttempt(
nonce,
provider,
requireMemberAccess,
returnPath,
timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime,
purpose,
expectedAccountId,
pendingLinkId,
target,
proof);
return new HeimdallAuthStartResult(
new Uri(payload.AuthorizationUrl, UriKind.Absolute), replaceSlot ?? ChooseSlot(requestCookies), Protect(attempt));
}
/// <summary>A start that cannot reach Heimdall is a refusal like any other, never a 500.</summary>
private async Task<HttpResponseMessage> SendStartAsync(HttpRequestMessage message, CancellationToken cancellationToken)
{
try
{
return await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
}
catch (Exception error) when (error is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
throw new HeimdallAuthException("Heimdall could not be reached for the auth start.");
}
}
/// <summary>
/// The provider an ordinary proof sign-in uses, or null when none is safe. A sign-in through a provider the account no
/// longer holds makes Heimdall mint an unrelated account that holds that login (Discord and Patreon; Google refuses instead), so it is never guessed: with the account's
/// current list, the first held one of the person's choice, Discord, Patreon, Google; with no list, only a provider the
/// member named (<paramref name="via"/>), who is the one who knows what they hold. The session's provider claim is not
/// consulted: it records how they signed in once, not what the account holds now.
/// </summary>
public static string? ProofProvider(IReadOnlyCollection<string>? held, string? via)
{
var chosen = via is not null && ProofProviders.Contains(via) ? via.ToLowerInvariant() : null;
if (held is null)
{
return chosen;
}
return new[] { chosen, "discord", "patreon", "google" }
.FirstOrDefault(x => x is not null && held.Contains(x, StringComparer.OrdinalIgnoreCase));
}
private string ProofProviderFor(string accountId, string? via) =>
ProofProvider(snapshots.Get(accountId)?.Select(x => x.Provider).ToArray(), via)
?? throw new HeimdallAuthException("No provider the account is known to hold can prove it.");
/// <summary>
/// The page's proof sign-ins. Each is a Heimdall sign-in for the account already signed in here, kept as an attempt
/// that says what it is the proof for (list, link, unlink) and which person it must prove. The session is never replaced.
/// </summary>
private Task<HeimdallAuthStartResult> StartProofAsync(
HeimdallAttemptPurpose purpose,
string provider,
string sessionAccountId,
string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies,
bool stepUp,
string? target,
CancellationToken cancellationToken,
string? proof = null) =>
StartAsync(
provider,
callbackBaseUrl,
LocalReturnPath.Parse(LinkReturnPath),
requireMemberAccess: false,
requestCookies,
cancellationToken,
purpose,
sessionAccountId,
stepUp: stepUp,
target: target,
proof: proof);
/// <summary>
/// Reads the account's logins: an ordinary sign-in, then GET /v1/me/identities inside its completion. Through a provider on
/// the current list, or, when there is none, the one the member names in <paramref name="via"/>.
/// </summary>
public Task<HeimdallAuthStartResult> StartListAsync(
string sessionAccountId, string? via, string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
StartProofAsync(
HeimdallAttemptPurpose.ListProof, ProofProviderFor(sessionAccountId, via),
sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken);
/// <summary>
/// Links another provider: an ordinary sign-in as proof through a provider on the account's current list (there is no
/// guessing without one), then the link start for <paramref name="targetProvider"/> with its bearer.
/// </summary>
public Task<HeimdallAuthStartResult> StartLinkAsync(
string sessionAccountId, string targetProvider, string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken)
{
if (!LinkableProviders.Contains(targetProvider))
{
throw new HeimdallAuthException("Unsupported provider to link.");
}
return StartProofAsync(
HeimdallAttemptPurpose.LinkStart, ProofProviderFor(sessionAccountId, null),
sessionAccountId, callbackBaseUrl, requestCookies, false, targetProvider.ToLowerInvariant(), cancellationToken);
}
/// <summary>
/// Unlinks one login: a step-up sign-in through Google, then POST /v1/me/identities/{id}/unlink with its bearer. The
/// identity must be on the account's current snapshot and the account must hold Google; otherwise no proof can be had
/// and Heimdall is not asked.
/// </summary>
public Task<HeimdallAuthStartResult> StartUnlinkAsync(
string sessionAccountId, string identityId, string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
StartProofAsync(
HeimdallAttemptPurpose.UnlinkProof, StepUpProvider, sessionAccountId, callbackBaseUrl, requestCookies, true, identityId, cancellationToken);
/// <summary>
/// The fresh sign-in that earns an enrolment ticket (docs/passkeys.md): an ordinary sign-in through a provider the account
/// holds, proving the account to this browser's session. Its completion issues the ticket; no passkey exists before it.
/// </summary>
public Task<HeimdallAuthStartResult> StartEnrolProofAsync(
string sessionAccountId, string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
StartProofAsync(
HeimdallAttemptPurpose.EnrolProof, ProofProviderFor(sessionAccountId, null),
sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken);
/// <summary>
/// Unlinks one login on a verified passkey assertion: an ordinary fresh sign-in as the account (never a guessed provider), and
/// inside its completion, with that bearer, Heimdall's app-asserted step-up and then the unlink. <paramref name="factId"/> is the
/// id of the single-use assertion fact; the attempt carries it and nothing else about the passkey.
/// </summary>
public Task<HeimdallAuthStartResult> StartUnlinkPasskeyAsync(
string sessionAccountId, string identityId, string factId, string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
StartProofAsync(
HeimdallAttemptPurpose.UnlinkPasskeyProof, ProofProviderFor(sessionAccountId, null),
sessionAccountId, callbackBaseUrl, requestCookies, false, identityId, cancellationToken, factId);
/// <summary>
/// The providers Heimdall's bifrost profile admits (src/app-profiles.ts, <c>identityProviders</c>): a link or a proof can use them.
/// Twitch is offered for sign-in here but the profile refuses it, so it is not linkable.
/// </summary>
public static readonly IReadOnlyList<string> LinkableProviders = ["discord", "patreon", "google"];
private static readonly HashSet<string> ProofProviders = new(LinkableProviders, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The one provider whose step-up a bifrost sign-in can use (docs/service-contract.md, step-up: Discord and Patreon cannot
/// re-authenticate, Twitch and YouTube are not in the bifrost profile). Heimdall answers 503 until a Google client is configured.
/// </summary>
public const string StepUpProvider = "google";
public const string LinkReturnPath = "/Membership/LinkedAccounts";
/// <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
/// overwrite each other; the Cookie header holds at most <see cref="HeimdallAttemptCookie.SlotNames"/> attempt
/// cookies however many starts a browser makes.
/// </summary>
private string ChooseSlot(IEnumerable<KeyValuePair<string, string>> requestCookies)
{
var cookies = requestCookies.ToLookup(x => x.Key, x => x.Value, StringComparer.Ordinal);
string? oldest = null;
var oldestExpiry = DateTimeOffset.MaxValue;
foreach (var name in HeimdallAttemptCookie.SlotNames)
{
var live = Read(cookies[name].FirstOrDefault());
if (live is null)
{
return name;
}
if (live.ExpiresAt < oldestExpiry)
{
oldest = name;
oldestExpiry = live.ExpiresAt;
}
}
return oldest!;
}
/// <summary>
/// The slot cookie that holds the attempt for this nonce: its name and value, or null when the browser holds
/// none (never started here, expired, or overwritten by a later start).
/// </summary>
public (string Name, string Value)? FindAttemptCookie(IEnumerable<KeyValuePair<string, string>> requestCookies, string? nonce)
{
foreach (var (name, value) in requestCookies)
{
if (HeimdallAttemptCookie.SlotNames.Contains(name) && Unprotect(value, nonce) is not null)
{
return (name, value);
}
}
return null;
}
/// <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, HeimdallRedeemResponse Redeemed)> RedeemVerifiedAsync(
string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(code))
{
throw new HeimdallAuthException("Heimdall completion carried no code.");
}
var redeemed = await RedeemAsync(code, cancellationToken);
if (!string.Equals(redeemed.ReturnTo, ToAttemptReturnUrl(callbackBaseUrl, attempt.Nonce), StringComparison.Ordinal))
{
throw new HeimdallAuthException("Heimdall completion belongs to another attempt.");
}
if (string.IsNullOrWhiteSpace(redeemed.AccessToken))
{
throw new HeimdallAuthException("Heimdall completion carried no access token.");
}
var identity = await tokenVerifier.VerifyAsync(redeemed.AccessToken, redeemed.Account?.Id, cancellationToken);
return (identity, redeemed.AccessToken, redeemed);
}
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.");
}
var userAccount = await UpsertUserAccountAsync(
identity,
attempt.Provider,
attempt.RequiresMemberAccess,
cancellationToken);
return new HeimdallCompletedSignIn(
bifrostIdentityService.BuildPrincipal(userAccount, attempt.Provider),
attempt.ReturnPath);
}
/// <summary>
/// One step of a Linked accounts action, chosen by the purpose of the attempt this browser holds. The browser's session
/// must be the account the action 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.
/// The bearer a step redeems is used inside the step and dropped.
/// </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 StartLinkProviderAsync(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(
attempt.Provider,
callbackBaseUrl,
attempt.ReturnPath,
requireMemberAccess: false,
[],
cancellationToken,
HeimdallAttemptPurpose.LinkConfirm,
attempt.ExpectedAccountId,
pendingLinkId,
replaceSlot: cookieName);
return new HeimdallLinkStep(next.AuthorizationUrl, next.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);
snapshots.Remove(identity.AccountId);
}
return new HeimdallLinkStep(null, cookieName, null, outcome);
}
case HeimdallAttemptPurpose.LinkAwaitDirect:
{
// A provider other than Google attaches in Heimdall's callback; the browser returns with a link completion.
var (identity, _, redeemed) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken);
if (redeemed.Mode != "link" || !string.Equals(redeemed.Provider, attempt.Target, StringComparison.Ordinal))
{
throw new HeimdallAuthException("Heimdall completion is not this link.");
}
await UpsertUserAccountAsync(identity, attempt.Target!, grantMemberAccess: false, cancellationToken);
snapshots.Remove(identity.AccountId);
return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.Linked);
}
case HeimdallAttemptPurpose.ListProof:
{
var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken);
var listed = await memberClient.ListAsync(bearer, cancellationToken);
if (listed.Call == HeimdallMemberCall.Ok)
{
snapshots.Put(identity.AccountId, listed.Identities!);
}
return new HeimdallLinkStep(null, cookieName, null, listed.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Listed : OutcomeOf(listed.Call));
}
case HeimdallAttemptPurpose.UnlinkProof:
{
var (identity, bearer, redeemed) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken);
if (redeemed.StepUpRequested != true)
{
// Heimdall reports "asked" on a step-up completion; one that does not was not a step-up sign-in.
return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.StepUpRequired);
}
return new HeimdallLinkStep(null, cookieName, null, await UnlinkAsync(identity.AccountId, bearer, attempt.Target, cancellationToken));
}
case HeimdallAttemptPurpose.EnrolProof:
{
// A fresh ordinary sign-in as the session's own account: the one thing a stolen session cookie cannot produce.
// It earns a short-lived single-use ticket, handed to this browser in a cookie; no passkey exists yet.
var (identity, _) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken);
var ticket = ceremonies.Issue(CeremonyKind.EnrolTicket, identity.AccountId, null, null, PasskeyCeremonies.TicketLifetime);
return new HeimdallLinkStep(
null, cookieName, null, ticket is null ? HeimdallLinkOutcome.Error : HeimdallLinkOutcome.EnrolReady, ticket);
}
case HeimdallAttemptPurpose.UnlinkPasskeyProof:
{
// The assertion fact is spent before anything else: missing, expired, spent, or for another account or identity
// means no step-up is asked of Heimdall and nothing is unlinked.
var fact = ceremonies.Take(CeremonyKind.UnlinkProof, attempt.ExpectedAccountId, attempt.Target, attempt.Proof);
var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken);
if (fact is null)
{
return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.PasskeyExpired);
}
// A passkey revoked since the assertion no longer proves anything: Heimdall is not asked.
if (!await passkeys.StillStrongAsync(identity.AccountId, fact.Data, cancellationToken))
{
return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.PasskeyFailed);
}
var asserted = await StepUpAsync(bearer, cancellationToken);
if (asserted.Refusal is { } refusal)
{
return new HeimdallLinkStep(null, cookieName, null, refusal);
}
// The returned token is this request's alone: it goes to the one unlink and is dropped.
return new HeimdallLinkStep(null, cookieName, null, await UnlinkAsync(identity.AccountId, asserted.AccessToken!, attempt.Target, cancellationToken));
}
default:
throw new HeimdallAuthException("Heimdall completion is not a link attempt.");
}
}
/// <summary>
/// Heimdall's unlink with a bearer that carries fresh proof, and what the list becomes. Only Heimdall's definitive refusals leave
/// the account as the list showed it. Any other end (not found, a lost answer, a 500, a malformed 200) may have applied the
/// unlink, so the list is dropped rather than trusted.
/// </summary>
private async Task<HeimdallLinkOutcome> UnlinkAsync(string accountId, string bearer, string? identityId, CancellationToken cancellationToken)
{
var unlinked = await memberClient.UnlinkAsync(bearer, identityId ?? string.Empty, cancellationToken);
if (unlinked.Call == HeimdallMemberCall.Ok)
{
snapshots.Put(accountId, unlinked.Identities!);
}
else if (unlinked.Call is not (HeimdallMemberCall.SignInRequired or HeimdallMemberCall.StepUpRequired or HeimdallMemberCall.LastIdentity))
{
snapshots.Remove(accountId);
}
return unlinked.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Unlinked : OutcomeOf(unlinked.Call);
}
/// <summary>The method label Heimdall's bifrost profile admits for an app-asserted passkey proof.</summary>
public const string PasskeyStepUpMethod = "bifrost-passkey";
/// <summary>
/// POST /v1/apps/{app}/step-up (Heimdall docs/service-contract.md): the app secret, the member's live bearer and a fresh random
/// nonce; Heimdall answers a token for the same session that carries the proof. Called only from the completion that holds the
/// bearer, after a verified passkey assertion fact was spent. Each refusal is a closed outcome; nothing is retried here.
/// </summary>
private async Task<(HeimdallLinkOutcome? Refusal, string? AccessToken)> StepUpAsync(string bearer, CancellationToken cancellationToken)
{
var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
try
{
using var message = new HttpRequestMessage(
HttpMethod.Post, $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/step-up")
{
Content = JsonContent.Create(new HeimdallStepUpRequest(PasskeyStepUpMethod, nonce))
};
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode == HttpStatusCode.Created)
{
var minted = await response.Content.ReadFromJsonAsync<HeimdallStepUpResponse>(cancellationToken);
return string.IsNullOrWhiteSpace(minted?.AccessToken) ? (HeimdallLinkOutcome.Error, null) : (null, minted.AccessToken);
}
var error = await HeimdallRefusal.CodeAsync(response, cancellationToken);
return ((int)response.StatusCode, error) switch
{
(401, "sign_in_required") => (HeimdallLinkOutcome.SignInRequired, null),
(429, _) => (HeimdallLinkOutcome.StepUpRateLimited, null),
(409, "step_up_nonce_replayed") => (HeimdallLinkOutcome.StepUpReplayed, null),
(401 or 403 or 400, _) => (HeimdallLinkOutcome.StepUpUnavailable, null),
_ => (HeimdallLinkOutcome.Error, null)
};
}
catch (Exception exception) when (exception is HttpRequestException or JsonException or NotSupportedException or TaskCanceledException
&& !cancellationToken.IsCancellationRequested)
{
return (HeimdallLinkOutcome.Error, null);
}
}
private static HeimdallLinkOutcome OutcomeOf(HeimdallMemberCall call) => call switch
{
HeimdallMemberCall.SignInRequired => HeimdallLinkOutcome.SignInRequired,
HeimdallMemberCall.StepUpRequired => HeimdallLinkOutcome.StepUpRequired,
HeimdallMemberCall.IdentityNotFound => HeimdallLinkOutcome.IdentityNotFound,
HeimdallMemberCall.LastIdentity => HeimdallLinkOutcome.LastIdentity,
_ => HeimdallLinkOutcome.Error
};
/// <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 (identity, bearer, _) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken);
return (identity, bearer);
}
private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken, HeimdallRedeemResponse Redeemed)> RedeemOwnerWithBodyAsync(
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;
}
/// <summary>
/// Starts the link to the attempt's target provider with the member's bearer. Google attaches in two phases (the
/// callback writes a pending link, a second fresh sign-in confirms it); the other admitted providers attach in the callback.
/// A cookie written before the target existed names no provider and is a Google link.
/// </summary>
private async Task<(Uri Url, string CookieValue)> StartLinkProviderAsync(
string callbackBaseUrl, HeimdallAttempt attempt, string bearer, CancellationToken cancellationToken)
{
var target = attempt.Target ?? "google";
if (!LinkableProviders.Contains(target))
{
throw new HeimdallAuthException("Unsupported provider to link.");
}
var options = heimdallOptions.Value;
var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
using var message = new HttpRequestMessage(HttpMethod.Post, $"/v1/oauth/{target}/start")
{
Content = JsonContent.Create(new HeimdallStartRequest(
options.AppSlug, "link", ToAttemptReturnUrl(callbackBaseUrl, nonce), new HeimdallHandoff("browser_completion"), null, null))
};
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
using var response = await SendStartAsync(message, 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,
target == "google" ? HeimdallAttemptPurpose.LinkAwaitGoogle : HeimdallAttemptPurpose.LinkAwaitDirect,
attempt.ExpectedAccountId,
null,
target);
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
{
using var response = await httpClient.PostAsJsonAsync(
$"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/auth-completions/redeem",
new HeimdallRedeemRequest(code),
cancellationToken);
if (response.StatusCode != HttpStatusCode.Created)
{
throw new HeimdallAuthException("Heimdall did not redeem the completion code.");
}
return await response.Content.ReadFromJsonAsync<HeimdallRedeemResponse>(cancellationToken)
?? throw new HeimdallAuthException("Heimdall redeem returned no body.");
}
catch (Exception error) when (error is not HeimdallAuthException && !cancellationToken.IsCancellationRequested)
{
throw new HeimdallAuthException("Heimdall redeem could not be read.");
}
}
private string Protect(HeimdallAttempt attempt) =>
_protector.Protect(JsonSerializer.Serialize(new AttemptWire(
attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt,
attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId, attempt.Target, attempt.Proof)));
/// <summary>The live attempt a cookie value protects when it belongs to this nonce, else null.</summary>
private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) =>
Read(cookieValue) is { } attempt && string.Equals(attempt.Nonce, nonce, StringComparison.Ordinal) ? attempt : null;
/// <summary>The live attempt a cookie value protects, else null.</summary>
private HeimdallAttempt? Read(string? cookieValue)
{
if (string.IsNullOrEmpty(cookieValue))
{
return null;
}
try
{
var wire = JsonSerializer.Deserialize<AttemptWire>(_protector.Unprotect(cookieValue));
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.Purpose, wire.ExpectedAccountId, wire.PendingLinkId, wire.Target, wire.Proof);
}
catch (Exception error) when (error is CryptographicException or JsonException or FormatException)
{
return null;
}
}
private sealed record AttemptWire(
string Nonce,
string Provider,
bool RequiresMemberAccess,
string ReturnPath,
DateTimeOffset ExpiresAt,
HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn,
string? ExpectedAccountId = null,
string? PendingLinkId = null,
string? Target = null,
string? Proof = null);
private async Task<UserAccount> UpsertUserAccountAsync(
HeimdallVerifiedIdentity identity,
string provider,
bool grantMemberAccess,
CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var accountId = identity.AccountId;
var displayName = identity.DisplayName ?? accountId;
var userAccount = await dbContext.UserAccounts
.Include(x => x.MemberProfile)
.Include(x => x.Membership!)
.ThenInclude(x => x.RoleAssignments)
.Include(x => x.Membership!)
.ThenInclude(x => x.TierSnapshots)
.SingleOrDefaultAsync(x => x.HeimdallAccountId == accountId, cancellationToken);
if (userAccount is null)
{
userAccount = new UserAccount
{
HeimdallAccountId = accountId,
DisplayName = displayName,
CreatedAtUtc = now,
LastSeenAtUtc = now,
MemberProfile = new MemberProfile
{
Nickname = displayName,
Headline = $"Verified through Heimdall {provider}",
UpdatedAtUtc = now
},
Membership = new Bifrost.Web.Domain.Membership
{
Status = grantMemberAccess ? MembershipStatus.Active : MembershipStatus.Authenticated,
CreatedAtUtc = now,
ApprovedAtUtc = grantMemberAccess ? now : null,
Notes = grantMemberAccess
? "Activated by Heimdall member access claim"
: $"Identity verified through Heimdall {provider}"
}
};
dbContext.UserAccounts.Add(userAccount);
}
else
{
userAccount.DisplayName = displayName;
userAccount.LastSeenAtUtc = now;
userAccount.MemberProfile ??= new MemberProfile
{
UserAccountId = userAccount.Id,
Nickname = displayName,
Headline = $"Verified through Heimdall {provider}",
UpdatedAtUtc = now
};
userAccount.Membership ??= new Bifrost.Web.Domain.Membership
{
UserAccountId = userAccount.Id,
CreatedAtUtc = now
};
// A suspension is an admin's decision: the claim neither lifts it nor, absent, imposes it.
if (grantMemberAccess && userAccount.Membership.Status != MembershipStatus.Suspended)
{
userAccount.Membership.Status = MembershipStatus.Active;
userAccount.Membership.ApprovedAtUtc ??= now;
}
}
userAccount.MemberProfile!.Nickname = displayName;
userAccount.MemberProfile.UpdatedAtUtc = now;
await bifrostIdentityService.EnsureIdentityAsync(
userAccount,
displayName,
cancellationToken);
if (grantMemberAccess && userAccount.Membership!.Status == MembershipStatus.Active)
{
ApplicationBootstrapper.EnsureRole(
userAccount.Membership!,
MemberRole.StandardMember,
null,
now,
"Heimdall member access");
}
await dbContext.SaveChangesAsync(cancellationToken);
return userAccount;
}
private static HeimdallEntitlementPolicy BuildDiscordPolicy(HeimdallOptions options)
{
if (!options.IsDiscordConfigured)
{
throw new HeimdallAuthException("Heimdall Discord access is not configured.");
}
return new HeimdallEntitlementPolicy(
"discord_role_access",
options.DiscordGuildId,
options.DiscordAllowedRoleIds,
null);
}
private static HeimdallEntitlementPolicy BuildPatreonPolicy(HeimdallOptions options)
{
if (!options.IsPatreonConfigured)
{
throw new HeimdallAuthException("Heimdall Patreon access is not configured.");
}
return new HeimdallEntitlementPolicy(
"patreon_membership_access",
null,
null,
options.PatreonTierTitle);
}
private static HeimdallEntitlementPolicy BuildMemberAccessPolicy(string provider, HeimdallOptions options)
{
if (provider.Equals("discord", StringComparison.OrdinalIgnoreCase))
{
return BuildDiscordPolicy(options);
}
if (provider.Equals("patreon", StringComparison.OrdinalIgnoreCase))
{
return BuildPatreonPolicy(options);
}
throw new HeimdallAuthException("Heimdall provider does not support Bifrost member access.");
}
private static string ToAttemptReturnUrl(string callbackBaseUrl, string nonce)
{
var baseUri = new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute);
return $"{new Uri(baseUri, "auth/heimdall/complete")}?attempt={Uri.EscapeDataString(nonce)}";
}
}
public class HeimdallAuthException(string message) : InvalidOperationException(message);
public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string CookieName, string CookieValue);
public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, LocalReturnPath ReturnPath);
/// <summary>An attempt Start issued. It exists only inside its protected cookie.</summary>
public sealed record HeimdallAttempt(
string Nonce,
string Provider,
bool RequiresMemberAccess,
LocalReturnPath ReturnPath,
DateTimeOffset ExpiresAt,
HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn,
string? ExpectedAccountId = null,
string? PendingLinkId = null,
string? Target = null,
string? Proof = 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 other purposes belong to the Linked accounts page: the fresh sign-ins that prove an account for a list, a link or
/// an unlink, and the provider round trips of a link. They never sign anyone in. Values are persisted in cookies: append only.
/// </summary>
public enum HeimdallAttemptPurpose
{
SignIn = 0,
LinkStart = 1,
LinkAwaitGoogle = 2,
LinkConfirm = 3,
ListProof = 4,
UnlinkProof = 5,
LinkAwaitDirect = 6,
EnrolProof = 7,
UnlinkPasskeyProof = 8
}
/// <summary>How a Linked accounts action ended. <see cref="HeimdallLinkOutcomes.Code"/> is its closed wire form.</summary>
public enum HeimdallLinkOutcome
{
Linked,
IdentityLinkedElsewhere,
Expired,
Error,
Listed,
Unlinked,
LastIdentity,
StepUpRequired,
IdentityNotFound,
SignInRequired,
ProofUnavailable,
ListExpired,
ProviderNeeded,
EnrolReady,
EnrolNeedsSignIn,
PasskeyAdded,
PasskeyRevoked,
PasskeyLimit,
PasskeyExpired,
PasskeyFailed,
PasskeyRequired,
PasskeysUnavailable,
RateLimited,
StepUpUnavailable,
StepUpReplayed,
StepUpRateLimited
}
/// <summary>
/// Where a Linked accounts step sends the browser: to <see cref="HeimdallLinkStep.Next"/> with a new attempt cookie in the held
/// slot, or, when the action is finished, back to the page with the outcome's code in <c>?result=</c>.
/// </summary>
public static class HeimdallLinkOutcomes
{
public const string ResultParameter = "result";
public static string Redirect(HeimdallLinkOutcome outcome) =>
HeimdallAuthService.LinkReturnPath + "?" + ResultParameter + "=" + Code(outcome);
public static string Code(HeimdallLinkOutcome outcome) => outcome switch
{
HeimdallLinkOutcome.Linked => "linked",
HeimdallLinkOutcome.IdentityLinkedElsewhere => "identity_linked_elsewhere",
HeimdallLinkOutcome.Expired => "expired",
HeimdallLinkOutcome.Listed => "listed",
HeimdallLinkOutcome.Unlinked => "unlinked",
HeimdallLinkOutcome.LastIdentity => "last_identity",
HeimdallLinkOutcome.StepUpRequired => "step_up_required",
HeimdallLinkOutcome.IdentityNotFound => "identity_not_found",
HeimdallLinkOutcome.SignInRequired => "sign_in_required",
HeimdallLinkOutcome.ProofUnavailable => "proof_unavailable",
HeimdallLinkOutcome.ListExpired => "list_expired",
HeimdallLinkOutcome.ProviderNeeded => "provider_needed",
HeimdallLinkOutcome.EnrolReady => "enrol_ready",
HeimdallLinkOutcome.EnrolNeedsSignIn => "enrol_needs_sign_in",
HeimdallLinkOutcome.PasskeyAdded => "passkey_added",
HeimdallLinkOutcome.PasskeyRevoked => "passkey_revoked",
HeimdallLinkOutcome.PasskeyLimit => "passkey_limit",
HeimdallLinkOutcome.PasskeyExpired => "passkey_expired",
HeimdallLinkOutcome.PasskeyFailed => "passkey_failed",
HeimdallLinkOutcome.PasskeyRequired => "passkey_required",
HeimdallLinkOutcome.PasskeysUnavailable => "passkeys_unavailable",
HeimdallLinkOutcome.RateLimited => "rate_limited",
HeimdallLinkOutcome.StepUpUnavailable => "step_up_unavailable",
HeimdallLinkOutcome.StepUpReplayed => "step_up_replayed",
HeimdallLinkOutcome.StepUpRateLimited => "step_up_rate_limited",
_ => "error"
};
/// <summary>The words for an outcome code: a closed set, never Heimdall's text and never the query's. Anything else gets none.</summary>
public static string? Wording(string? code) => code switch
{
"linked" => "The login is now linked to your Heimdall account.",
"identity_linked_elsewhere" => "That login belongs to another account. Nothing was linked.",
"expired" => "The link expired before it was confirmed. Nothing was linked; start the link again.",
"listed" => null,
"unlinked" => "The login was unlinked.",
"last_identity" => "That is the last login on your account, and Heimdall will not remove it. Nothing was changed.",
"step_up_required" => "Heimdall did not accept the sign-in as fresh proof. Nothing was changed; try again.",
"identity_not_found" => "That login is no longer on your account. Nothing was changed; the list below is read again when you ask.",
"sign_in_required" => "Heimdall no longer accepts that sign-in. Nothing was changed; try again.",
"proof_unavailable" => "Fresh proof could not be started, so nothing was changed. Unlinking needs a fresh Google sign-in, and it is not available right now.",
"provider_needed" => "Bifrost does not know which logins this account holds right now, so it will not guess. Pick one you know it holds to read the list. Nothing was changed.",
"enrol_ready" => "You signed in again. Add the passkey below within five minutes.",
"enrol_needs_sign_in" => "Adding a passkey needs a fresh sign-in first, and none is current. Start again with \"Sign in again to add a passkey\". Nothing was added.",
"passkey_added" => "The passkey was added. It counts as strong proof after a 48 hour cooling-off; until then it cannot unlink anything.",
"passkey_revoked" => "The passkey was revoked.",
"passkey_limit" => "This account already has the most passkeys it can hold. Revoke one to add another.",
"passkey_expired" => "The passkey check expired or was already used. Nothing was changed; start the unlink again.",
"passkey_failed" => "The passkey could not be verified. Nothing was changed.",
"passkey_required" => "This account has a passkey, so unlinking needs it. Nothing was changed.",
"passkeys_unavailable" => "Passkeys are not available on this server right now. Nothing was changed.",
"rate_limited" => "Too many passkey attempts. Nothing was changed; wait a few minutes.",
"step_up_unavailable" => "Your passkey was verified, but Heimdall would not take Bifrost's proof. Nothing was changed.",
"step_up_replayed" => "Heimdall had already seen that proof. Nothing was changed; start the unlink again.",
"step_up_rate_limited" => "Heimdall is limiting proofs for now. Nothing was changed; wait a few minutes and start the unlink again.",
"list_expired" => "The list was out of date. Nothing was changed; read your linked logins again.",
"error" => "That could not be completed. Nothing was changed.",
_ => null
};
}
public sealed record HeimdallLinkStep(Uri? Next, string CookieName, string? CookieValue, HeimdallLinkOutcome? Outcome, string? EnrolTicket = null);
public sealed record HeimdallConfirmLinkRequest(
[property: JsonPropertyName("pendingLinkId")] string PendingLinkId);
public sealed record HeimdallStepUpRequest(
[property: JsonPropertyName("method")] string Method,
[property: JsonPropertyName("nonce")] string Nonce);
public sealed record HeimdallStepUpResponse(
[property: JsonPropertyName("accessToken")] string? AccessToken);
public sealed record HeimdallConfirmLinkResponse(
[property: JsonPropertyName("status")] string? Status);
public sealed record HeimdallStartRequest(
[property: JsonPropertyName("appSlug")] string AppSlug,
[property: JsonPropertyName("mode")] string Mode,
[property: JsonPropertyName("returnTo")] string ReturnTo,
[property: JsonPropertyName("handoff")] HeimdallHandoff Handoff,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("stepUp")] bool? StepUp = null);
public sealed record HeimdallHandoff(
[property: JsonPropertyName("kind")] string Kind);
public sealed record HeimdallEntitlementPolicy(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("guildId")] string? GuildId,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("allowedRoleIds")] string[]? AllowedRoleIds,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("requiredTierTitle")] string? RequiredTierTitle);
public sealed record HeimdallStartResponse(
[property: JsonPropertyName("authorizationUrl")] string AuthorizationUrl);
public sealed record HeimdallRedeemRequest(
[property: JsonPropertyName("completionCode")] string CompletionCode);
public sealed record HeimdallRedeemResponse(
[property: JsonPropertyName("returnTo")] string? ReturnTo,
[property: JsonPropertyName("accessToken")] string? AccessToken,
[property: JsonPropertyName("account")] HeimdallAccount? Account,
[property: JsonPropertyName("mode")] string? Mode = null,
[property: JsonPropertyName("provider")] string? Provider = null,
[property: JsonPropertyName("stepUpRequested")] bool? StepUpRequested = null);
public sealed record HeimdallAccount(
[property: JsonPropertyName("id")] string Id);
/// <summary>
/// The attempt cookies: a ring of <see cref="SlotNames"/> fixed names, so the Cookie header a browser sends to
/// every path is bounded by construction (the __Host- prefix forces Path=/; a worst-case attempt is about 2.4 KB, nginx's line limit is 8 KB).
/// A start takes a free slot or overwrites the one expiring first; an overwritten attempt finds no cookie and
/// is refused. The attempt's nonce lives inside the protected value, never in the cookie name.
/// </summary>
public static class HeimdallAttemptCookie
{
public const string Prefix = BifrostCookies.HeimdallAttemptPrefix;
public const int SlotCount = 2;
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10);
public static readonly IReadOnlyList<string> SlotNames =
Enumerable.Range(0, SlotCount).Select(x => Prefix + x).ToArray();
public static CookieOptions Options() => new()
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Path = BifrostCookies.Path,
MaxAge = Lifetime
};
/// <summary>
/// The options a deletion carries: the attempt cookie's Path, which a cookie is matched on, and Secure, without
/// which a browser ignores a Set-Cookie for a __Host- name. It carries no Max-Age, which would take precedence
/// over the expiry Delete writes and leave the emptied cookie alive.
/// </summary>
public static CookieOptions DeleteOptions() => new() { Path = BifrostCookies.Path, Secure = true };
}