Merge hands/heimdall-callback-signed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b2110f53fa
14 changed files with 2765 additions and 164 deletions
|
|
@ -11,6 +11,7 @@
|
|||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ public sealed class HeimdallOptions
|
|||
|
||||
public string PatreonTierTitle { get; init; } = "Inner Sanctum";
|
||||
|
||||
public string AppSecret { get; init; } = string.Empty;
|
||||
|
||||
public string PatronSupportIntakeSecret { get; init; } = string.Empty;
|
||||
|
||||
public bool EnablePatronSupportIntake { get; init; } = true;
|
||||
|
|
@ -24,11 +26,13 @@ public sealed class HeimdallOptions
|
|||
|
||||
public bool IsDiscordConfigured =>
|
||||
IsConfigured &&
|
||||
!string.IsNullOrWhiteSpace(AppSecret) &&
|
||||
!string.IsNullOrWhiteSpace(DiscordGuildId) &&
|
||||
DiscordAllowedRoleIds.Length > 0;
|
||||
|
||||
public bool IsPatreonConfigured =>
|
||||
IsConfigured &&
|
||||
!string.IsNullOrWhiteSpace(AppSecret) &&
|
||||
!string.IsNullOrWhiteSpace(PatreonTierTitle);
|
||||
|
||||
public bool IsPatronSupportIntakeConfigured =>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,39 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Collections.Concurrent;
|
||||
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.Shared;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
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.
|
||||
/// </summary>
|
||||
public sealed class HeimdallAuthService(
|
||||
HttpClient httpClient,
|
||||
HeimdallAuthAttemptStore attemptStore,
|
||||
HeimdallTokenVerifier tokenVerifier,
|
||||
BifrostDbContext dbContext,
|
||||
BifrostIdentityService bifrostIdentityService,
|
||||
IOptions<HeimdallOptions> heimdallOptions,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
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",
|
||||
|
|
@ -29,16 +41,29 @@ public sealed class HeimdallAuthService(
|
|||
"twitch"
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HeimdallAuthStartResult> StartAsync(
|
||||
string provider,
|
||||
string callbackBaseUrl,
|
||||
string returnUrl,
|
||||
LocalReturnPath returnPath,
|
||||
bool requireMemberAccess,
|
||||
IEnumerable<KeyValuePair<string, string>> requestCookies,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!AllowedProviders.Contains(provider))
|
||||
{
|
||||
throw new HeimdallAuthException($"Unsupported Heimdall provider '{provider}'.");
|
||||
throw new HeimdallAuthException("Unsupported Heimdall provider.");
|
||||
}
|
||||
|
||||
var options = heimdallOptions.Value;
|
||||
|
|
@ -50,14 +75,13 @@ public sealed class HeimdallAuthService(
|
|||
var entitlementPolicy = requireMemberAccess
|
||||
? BuildMemberAccessPolicy(provider, options)
|
||||
: null;
|
||||
var attemptId = Guid.NewGuid().ToString("N");
|
||||
var callbackUrl = new Uri(new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute), "auth/heimdall/callback");
|
||||
var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
var request = new HeimdallStartRequest(
|
||||
options.AppSlug,
|
||||
"sign_in",
|
||||
ToAttemptReturnUrl(callbackBaseUrl, attemptId, returnUrl),
|
||||
new HeimdallHandoff("backend_callback", attemptId, callbackUrl.ToString()),
|
||||
ToAttemptReturnUrl(callbackBaseUrl, nonce),
|
||||
new HeimdallHandoff("browser_completion"),
|
||||
entitlementPolicy);
|
||||
|
||||
var response = await httpClient.PostAsJsonAsync(
|
||||
|
|
@ -67,8 +91,7 @@ public sealed class HeimdallAuthService(
|
|||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var detail = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
throw new HeimdallAuthException($"Heimdall rejected {provider} auth start: {detail}");
|
||||
throw new HeimdallAuthException("Heimdall rejected the auth start.");
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<HeimdallStartResponse>(cancellationToken);
|
||||
|
|
@ -77,59 +100,174 @@ public sealed class HeimdallAuthService(
|
|||
throw new HeimdallAuthException("Heimdall auth start returned no authorization URL.");
|
||||
}
|
||||
|
||||
attemptStore.Create(attemptId, returnUrl);
|
||||
attemptStore.SetRequiresMemberAccess(attemptId, requireMemberAccess);
|
||||
return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), attemptId);
|
||||
var attempt = new HeimdallAttempt(
|
||||
nonce,
|
||||
provider,
|
||||
requireMemberAccess,
|
||||
returnPath,
|
||||
timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime);
|
||||
return new HeimdallAuthStartResult(
|
||||
new Uri(payload.AuthorizationUrl, UriKind.Absolute), ChooseSlot(requestCookies), Protect(attempt));
|
||||
}
|
||||
|
||||
public void StoreBackendCallback(HeimdallBackendCallbackPayload payload)
|
||||
/// <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)
|
||||
{
|
||||
if (!string.Equals(payload.AppSlug, heimdallOptions.Value.AppSlug, StringComparison.OrdinalIgnoreCase))
|
||||
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)
|
||||
{
|
||||
throw new HeimdallAuthException("Heimdall callback targeted a different app.");
|
||||
var live = Read(cookies[name].FirstOrDefault());
|
||||
if (live is null)
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
if (live.ExpiresAt < oldestExpiry)
|
||||
{
|
||||
oldest = name;
|
||||
oldestExpiry = live.ExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.AttemptId))
|
||||
{
|
||||
throw new HeimdallAuthException("Heimdall callback omitted attempt id.");
|
||||
}
|
||||
|
||||
attemptStore.Complete(payload.AttemptId, payload);
|
||||
return oldest!;
|
||||
}
|
||||
|
||||
public async Task<HeimdallCompletedSignIn> CompleteAttemptAsync(
|
||||
string attemptId,
|
||||
/// <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;
|
||||
}
|
||||
|
||||
public async Task<HeimdallCompletedSignIn> CompleteAsync(
|
||||
string callbackBaseUrl,
|
||||
string? nonce,
|
||||
string? cookieValue,
|
||||
string? code,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attempt = attemptStore.TryTake(attemptId)
|
||||
?? throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet.");
|
||||
var payload = attempt.Payload
|
||||
?? throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet.");
|
||||
|
||||
if (!payload.IsSuccessful)
|
||||
var attempt = Unprotect(cookieValue, nonce)
|
||||
?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser.");
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
throw new HeimdallAuthException(payload.ErrorDescription ?? payload.Error ?? "Heimdall auth did not succeed.");
|
||||
throw new HeimdallAuthException("Heimdall completion carried no code.");
|
||||
}
|
||||
|
||||
if (attempt.RequiresMemberAccess && !payload.HasMemberAccess)
|
||||
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);
|
||||
if (attempt.RequiresMemberAccess && !identity.HasMemberAccess)
|
||||
{
|
||||
throw new HeimdallAuthException("Heimdall did not grant Bifrost member access.");
|
||||
}
|
||||
|
||||
var userAccount = await UpsertUserAccountAsync(payload, attempt.RequiresMemberAccess, cancellationToken);
|
||||
var userAccount = await UpsertUserAccountAsync(
|
||||
identity,
|
||||
attempt.Provider,
|
||||
attempt.RequiresMemberAccess,
|
||||
cancellationToken);
|
||||
return new HeimdallCompletedSignIn(
|
||||
bifrostIdentityService.BuildPrincipal(userAccount, payload.Provider ?? "heimdall"),
|
||||
string.IsNullOrWhiteSpace(attempt.ReturnUrl) ? "/App" : attempt.ReturnUrl);
|
||||
bifrostIdentityService.BuildPrincipal(userAccount, attempt.Provider),
|
||||
attempt.ReturnPath);
|
||||
}
|
||||
|
||||
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)));
|
||||
|
||||
/// <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())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new HeimdallAttempt(
|
||||
wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt);
|
||||
}
|
||||
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);
|
||||
|
||||
private async Task<UserAccount> UpsertUserAccountAsync(
|
||||
HeimdallBackendCallbackPayload payload,
|
||||
HeimdallVerifiedIdentity identity,
|
||||
string provider,
|
||||
bool grantMemberAccess,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var accountId = payload.Account?.Id ?? throw new HeimdallAuthException("Heimdall response omitted account id.");
|
||||
var displayName = payload.Account.DisplayName ?? accountId;
|
||||
var accountId = identity.AccountId;
|
||||
var displayName = identity.DisplayName ?? accountId;
|
||||
|
||||
var userAccount = await dbContext.UserAccounts
|
||||
.Include(x => x.MemberProfile)
|
||||
|
|
@ -150,7 +288,7 @@ public sealed class HeimdallAuthService(
|
|||
MemberProfile = new MemberProfile
|
||||
{
|
||||
Nickname = displayName,
|
||||
Headline = $"Verified through Heimdall {payload.Provider}",
|
||||
Headline = $"Verified through Heimdall {provider}",
|
||||
UpdatedAtUtc = now
|
||||
},
|
||||
Membership = new Bifrost.Web.Domain.Membership
|
||||
|
|
@ -160,7 +298,7 @@ public sealed class HeimdallAuthService(
|
|||
ApprovedAtUtc = grantMemberAccess ? now : null,
|
||||
Notes = grantMemberAccess
|
||||
? "Activated by Heimdall member access claim"
|
||||
: $"Identity verified through Heimdall {payload.Provider}"
|
||||
: $"Identity verified through Heimdall {provider}"
|
||||
}
|
||||
};
|
||||
dbContext.UserAccounts.Add(userAccount);
|
||||
|
|
@ -173,7 +311,7 @@ public sealed class HeimdallAuthService(
|
|||
{
|
||||
UserAccountId = userAccount.Id,
|
||||
Nickname = displayName,
|
||||
Headline = $"Verified through Heimdall {payload.Provider}",
|
||||
Headline = $"Verified through Heimdall {provider}",
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
userAccount.Membership ??= new Bifrost.Web.Domain.Membership
|
||||
|
|
@ -248,61 +386,29 @@ public sealed class HeimdallAuthService(
|
|||
return BuildPatreonPolicy(options);
|
||||
}
|
||||
|
||||
throw new HeimdallAuthException($"Heimdall provider '{provider}' does not support Bifrost member access.");
|
||||
throw new HeimdallAuthException("Heimdall provider does not support Bifrost member access.");
|
||||
}
|
||||
|
||||
private static string ToAttemptReturnUrl(string callbackBaseUrl, string attemptId, string returnUrl)
|
||||
private static string ToAttemptReturnUrl(string callbackBaseUrl, string nonce)
|
||||
{
|
||||
var baseUri = new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute);
|
||||
var target = string.IsNullOrWhiteSpace(returnUrl) ? "/App" : returnUrl;
|
||||
var waitUri = new Uri(baseUri, "auth/heimdall/wait");
|
||||
return $"{waitUri}?attemptId={Uri.EscapeDataString(attemptId)}&returnTo={Uri.EscapeDataString(target)}";
|
||||
return $"{new Uri(baseUri, "auth/heimdall/complete")}?attempt={Uri.EscapeDataString(nonce)}";
|
||||
}
|
||||
}
|
||||
|
||||
public class HeimdallAuthException(string message) : InvalidOperationException(message);
|
||||
|
||||
public sealed class HeimdallAuthPendingException(string message) : HeimdallAuthException(message);
|
||||
public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string CookieName, string CookieValue);
|
||||
|
||||
public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string AttemptId);
|
||||
public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, LocalReturnPath ReturnPath);
|
||||
|
||||
public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, string RedirectUri);
|
||||
|
||||
public sealed class HeimdallAuthAttemptStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, HeimdallAuthAttempt> _attempts = new(StringComparer.Ordinal);
|
||||
|
||||
public void Create(string attemptId, string returnUrl)
|
||||
{
|
||||
_attempts[attemptId] = new HeimdallAuthAttempt(returnUrl, null);
|
||||
}
|
||||
|
||||
public void SetRequiresMemberAccess(string attemptId, bool requiresMemberAccess)
|
||||
{
|
||||
_attempts.AddOrUpdate(
|
||||
attemptId,
|
||||
_ => new HeimdallAuthAttempt("/App", null, requiresMemberAccess),
|
||||
(_, existing) => existing with { RequiresMemberAccess = requiresMemberAccess });
|
||||
}
|
||||
|
||||
public void Complete(string attemptId, HeimdallBackendCallbackPayload payload)
|
||||
{
|
||||
_attempts.AddOrUpdate(
|
||||
attemptId,
|
||||
_ => new HeimdallAuthAttempt(payload.ReturnTo ?? "/App", payload),
|
||||
(_, existing) => existing with { Payload = payload });
|
||||
}
|
||||
|
||||
public HeimdallAuthAttempt? TryTake(string attemptId)
|
||||
{
|
||||
return _attempts.TryRemove(attemptId, out var attempt) ? attempt : null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record HeimdallAuthAttempt(
|
||||
string ReturnUrl,
|
||||
HeimdallBackendCallbackPayload? Payload,
|
||||
bool RequiresMemberAccess = false);
|
||||
/// <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);
|
||||
|
||||
public sealed record HeimdallStartRequest(
|
||||
[property: JsonPropertyName("appSlug")] string AppSlug,
|
||||
|
|
@ -313,11 +419,7 @@ public sealed record HeimdallStartRequest(
|
|||
[property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy);
|
||||
|
||||
public sealed record HeimdallHandoff(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[property: JsonPropertyName("attemptId")] string? AttemptId = null,
|
||||
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[property: JsonPropertyName("callbackUrl")] string? CallbackUrl = null);
|
||||
[property: JsonPropertyName("kind")] string Kind);
|
||||
|
||||
public sealed record HeimdallEntitlementPolicy(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
|
|
@ -331,31 +433,45 @@ public sealed record HeimdallEntitlementPolicy(
|
|||
public sealed record HeimdallStartResponse(
|
||||
[property: JsonPropertyName("authorizationUrl")] string AuthorizationUrl);
|
||||
|
||||
public sealed record HeimdallBackendCallbackPayload(
|
||||
[property: JsonPropertyName("attemptId")] string AttemptId,
|
||||
[property: JsonPropertyName("appSlug")] string? AppSlug,
|
||||
[property: JsonPropertyName("status")] string? Status,
|
||||
[property: JsonPropertyName("provider")] string? Provider,
|
||||
[property: JsonPropertyName("returnTo")] string? ReturnTo,
|
||||
[property: JsonPropertyName("error")] string? Error,
|
||||
[property: JsonPropertyName("errorDescription")] string? ErrorDescription,
|
||||
[property: JsonPropertyName("account")] HeimdallAccount? Account,
|
||||
[property: JsonPropertyName("sharedCapabilities")] string[]? SharedCapabilities,
|
||||
[property: JsonPropertyName("entitlements")] HeimdallEntitlements? Entitlements)
|
||||
{
|
||||
public bool IsSuccessful => string.Equals(Status, "success", StringComparison.OrdinalIgnoreCase);
|
||||
public sealed record HeimdallRedeemRequest(
|
||||
[property: JsonPropertyName("completionCode")] string CompletionCode);
|
||||
|
||||
public bool HasMemberAccess =>
|
||||
SharedCapabilities?.Any(capability =>
|
||||
string.Equals(capability, "member_access", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(capability, "app_access", StringComparison.OrdinalIgnoreCase)) == true ||
|
||||
Entitlements?.Facts?.Any(fact =>
|
||||
string.Equals(fact, "entitlement.app_access", StringComparison.OrdinalIgnoreCase)) == true;
|
||||
}
|
||||
public sealed record HeimdallRedeemResponse(
|
||||
[property: JsonPropertyName("returnTo")] string? ReturnTo,
|
||||
[property: JsonPropertyName("accessToken")] string? AccessToken,
|
||||
[property: JsonPropertyName("account")] HeimdallAccount? Account);
|
||||
|
||||
public sealed record HeimdallAccount(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("displayName")] string? DisplayName);
|
||||
[property: JsonPropertyName("id")] string Id);
|
||||
|
||||
public sealed record HeimdallEntitlements(
|
||||
[property: JsonPropertyName("facts")] string[]? Facts);
|
||||
/// <summary>
|
||||
/// The attempt cookies: a ring of <see cref="SlotNames"/> fixed names, so the Cookie header a browser sends to
|
||||
/// /auth/heimdall is bounded by construction (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 = "bifrost_heimdall_";
|
||||
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 = "/auth/heimdall",
|
||||
MaxAge = Lifetime
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The options a deletion carries: only the attempt cookie's Path, which a cookie is matched on. 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 = "/auth/heimdall" };
|
||||
}
|
||||
|
|
|
|||
46
src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs
Normal file
46
src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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.
|
||||
/// </summary>
|
||||
public static class HeimdallCompletePage
|
||||
{
|
||||
public const string Html = """
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>Signing in - Bifrost</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Finishing sign-in...</p>
|
||||
<noscript><p>JavaScript is needed to finish sign-in.</p></noscript>
|
||||
<form id="complete" method="post" action="/auth/heimdall/complete">
|
||||
<input type="hidden" name="attempt" />
|
||||
<input type="hidden" name="code" />
|
||||
</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 failed = fragment.get("heimdall_status") === "error";
|
||||
history.replaceState(null, "", location.pathname + location.search);
|
||||
if (failed || !code || !attempt) {
|
||||
location.replace("/?auth=heimdall-error");
|
||||
return;
|
||||
}
|
||||
var form = document.getElementById("complete");
|
||||
form.elements.attempt.value = attempt;
|
||||
form.elements.code.value = code;
|
||||
form.submit();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
284
src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs
Normal file
284
src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Org.BouncyCastle.Math.EC.Rfc8032;
|
||||
|
||||
namespace Bifrost.Web.Features.Heimdall;
|
||||
|
||||
/// <summary>
|
||||
/// The identity a Heimdall-signed access token asserts. Everything here comes from verified claims.
|
||||
/// </summary>
|
||||
public sealed record HeimdallVerifiedIdentity(string AccountId, string? DisplayName, bool HasMemberAccess);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Heimdall's access tokens (EdDSA) against Heimdall's published JWKS. Heimdall is the issuer
|
||||
/// at <see cref="HeimdallOptions.BaseUrl"/>; the audience is this app's slug. Failures name a rule and
|
||||
/// never echo the token or any of its contents. One instance serves the process: it owns the key cache.
|
||||
/// </summary>
|
||||
public sealed class HeimdallTokenVerifier(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<HeimdallOptions> heimdallOptions,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public const string TokenType = "heimdall_access";
|
||||
public const string ClientName = "heimdall-jwks";
|
||||
|
||||
internal static readonly TimeSpan ClockSkew = TimeSpan.FromSeconds(60);
|
||||
internal static readonly TimeSpan KeyRefreshInterval = TimeSpan.FromSeconds(30);
|
||||
internal static readonly TimeSpan KeyMaxAge = TimeSpan.FromHours(1);
|
||||
|
||||
private static readonly string[] MemberCapabilities = ["member_access", "app_access"];
|
||||
|
||||
private readonly SemaphoreSlim _fetchGate = new(1, 1);
|
||||
private volatile KeySnapshot _keys = new(new Dictionary<string, byte[]>(StringComparer.Ordinal), DateTimeOffset.MinValue);
|
||||
private DateTimeOffset _attemptedAt = DateTimeOffset.MinValue;
|
||||
|
||||
public async Task<HeimdallVerifiedIdentity> VerifyAsync(
|
||||
string token,
|
||||
string? payloadAccountId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
throw Refuse("token is not a compact JWS");
|
||||
}
|
||||
|
||||
var headerBytes = Decode(parts[0]);
|
||||
var payloadBytes = Decode(parts[1]);
|
||||
var signature = Decode(parts[2]);
|
||||
|
||||
using var header = ParseJson(headerBytes, "token header is not JSON");
|
||||
var kid = ReadKeyId(header.RootElement);
|
||||
var publicKey = await FindKeyAsync(kid, cancellationToken);
|
||||
if (publicKey is null)
|
||||
{
|
||||
throw Refuse("token key is not published");
|
||||
}
|
||||
|
||||
var signingInput = System.Text.Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
|
||||
if (signature.Length != Ed25519.SignatureSize ||
|
||||
!Ed25519.Verify(signature, 0, publicKey, 0, signingInput, 0, signingInput.Length))
|
||||
{
|
||||
throw Refuse("token signature is invalid");
|
||||
}
|
||||
|
||||
using var payload = ParseJson(payloadBytes, "token payload is not JSON");
|
||||
return CheckClaims(payload.RootElement, heimdallOptions.Value, payloadAccountId);
|
||||
}
|
||||
|
||||
private sealed record KeySnapshot(Dictionary<string, byte[]> Keys, DateTimeOffset FetchedAt);
|
||||
|
||||
private static byte[] Decode(string segment)
|
||||
{
|
||||
try
|
||||
{
|
||||
return WebEncoders.Base64UrlDecode(segment);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw Refuse("token is not base64url");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonDocument ParseJson(byte[] bytes, string rule)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonDocument.Parse(bytes);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw Refuse(rule);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadKeyId(JsonElement header)
|
||||
{
|
||||
if (header.ValueKind != JsonValueKind.Object || StringClaim(header, "alg") != "EdDSA")
|
||||
{
|
||||
throw Refuse("token algorithm is not EdDSA");
|
||||
}
|
||||
|
||||
var kid = StringClaim(header, "kid");
|
||||
if (string.IsNullOrEmpty(kid))
|
||||
{
|
||||
throw Refuse("token has no key id");
|
||||
}
|
||||
|
||||
return kid;
|
||||
}
|
||||
|
||||
private HeimdallVerifiedIdentity CheckClaims(JsonElement claims, HeimdallOptions options, string? payloadAccountId)
|
||||
{
|
||||
if (claims.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw Refuse("token payload is not an object");
|
||||
}
|
||||
|
||||
if (StringClaim(claims, "iss") != options.BaseUrl.TrimEnd('/'))
|
||||
{
|
||||
throw Refuse("token issuer is not Heimdall");
|
||||
}
|
||||
|
||||
if (StringClaim(claims, "aud") != options.AppSlug)
|
||||
{
|
||||
throw Refuse("token audience is not this app");
|
||||
}
|
||||
|
||||
if (StringClaim(claims, "typ") != TokenType)
|
||||
{
|
||||
throw Refuse("token type is not an access token");
|
||||
}
|
||||
|
||||
if (!claims.TryGetProperty("app", out var app) ||
|
||||
app.ValueKind != JsonValueKind.Object ||
|
||||
StringClaim(app, "slug") != options.AppSlug)
|
||||
{
|
||||
throw Refuse("token app is not this app");
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (NumericDate(claims, "nbf") is not { } notBefore || now < notBefore - ClockSkew)
|
||||
{
|
||||
throw Refuse("token is not yet valid");
|
||||
}
|
||||
|
||||
if (NumericDate(claims, "exp") is not { } expires || now - ClockSkew >= expires)
|
||||
{
|
||||
throw Refuse("token has expired");
|
||||
}
|
||||
|
||||
var subject = StringClaim(claims, "sub");
|
||||
if (string.IsNullOrEmpty(subject) ||
|
||||
subject != StringClaim(claims, "account_id") ||
|
||||
subject != payloadAccountId)
|
||||
{
|
||||
throw Refuse("token subject does not match the account");
|
||||
}
|
||||
|
||||
var capabilities = new List<string>();
|
||||
if (claims.TryGetProperty("capabilities", out var list) && list.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var capability in list.EnumerateArray())
|
||||
{
|
||||
if (capability.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
capabilities.Add(capability.GetString()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new HeimdallVerifiedIdentity(
|
||||
subject,
|
||||
StringClaim(claims, "display_name"),
|
||||
capabilities.Any(capability => MemberCapabilities.Contains(capability, StringComparer.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
/// <summary>The JWKS client waits a short, bounded time: a slow fetch must not hold sign-ins for long.</summary>
|
||||
public static void ConfigureClient(HttpClient client, HeimdallOptions options)
|
||||
{
|
||||
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/'));
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
private async Task<byte[]?> FindKeyAsync(string kid, CancellationToken cancellationToken)
|
||||
{
|
||||
if (TryFreshKey(kid, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
await _fetchGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
if (now - _attemptedAt >= KeyRefreshInterval)
|
||||
{
|
||||
_attemptedAt = now;
|
||||
_keys = new KeySnapshot(await FetchKeysAsync(cancellationToken), now);
|
||||
}
|
||||
|
||||
return _keys.Keys.GetValueOrDefault(kid);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_fetchGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryFreshKey(string kid, out byte[]? key)
|
||||
{
|
||||
var snapshot = _keys;
|
||||
key = null;
|
||||
return timeProvider.GetUtcNow() - snapshot.FetchedAt < KeyMaxAge && snapshot.Keys.TryGetValue(kid, out key);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, byte[]>> FetchKeysAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ReadKeysAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw Refuse("Heimdall keys could not be fetched");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, byte[]>> ReadKeysAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var httpClient = httpClientFactory.CreateClient(ClientName);
|
||||
using var response = await httpClient.GetAsync("/.well-known/jwks.json", cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var body = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken);
|
||||
var keys = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||
foreach (var jwk in document.RootElement.GetProperty("keys").EnumerateArray())
|
||||
{
|
||||
if (jwk.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = StringClaim(jwk, "kid");
|
||||
var x = StringClaim(jwk, "x");
|
||||
if (StringClaim(jwk, "kty") != "OKP" ||
|
||||
StringClaim(jwk, "crv") != "Ed25519" ||
|
||||
string.IsNullOrEmpty(id) ||
|
||||
x is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = WebEncoders.Base64UrlDecode(x);
|
||||
if (key.Length == Ed25519.PublicKeySize)
|
||||
{
|
||||
keys[id] = key;
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static string? StringClaim(JsonElement element, string name) =>
|
||||
element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
|
||||
private static DateTimeOffset? NumericDate(JsonElement element, string name)
|
||||
{
|
||||
if (!element.TryGetProperty(name, out var value) ||
|
||||
value.ValueKind != JsonValueKind.Number ||
|
||||
!value.TryGetInt64(out var seconds) ||
|
||||
seconds is < 0 or > 253402300799)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTimeOffset.FromUnixTimeSeconds(seconds);
|
||||
}
|
||||
|
||||
private static HeimdallAuthException Refuse(string rule) => new($"Heimdall token refused: {rule}.");
|
||||
}
|
||||
34
src/Bifrost.Web/Features/Shared/LocalReturnPath.cs
Normal file
34
src/Bifrost.Web/Features/Shared/LocalReturnPath.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
namespace Bifrost.Web.Features.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// A sign-in return target that stays on this site. The only way to make one is <see cref="Parse"/>, which
|
||||
/// accepts a path of at most <see cref="MaxLength"/> characters that starts with a single slash and holds no
|
||||
/// control character, and otherwise yields <see cref="Default"/>. Callers parse once at entry and carry the typed value from there.
|
||||
/// </summary>
|
||||
public sealed record LocalReturnPath
|
||||
{
|
||||
private LocalReturnPath(string value) => Value = value;
|
||||
|
||||
/// <summary>Bounds the path so the attempt cookie that carries it stays far below a browser's 4096-byte limit.</summary>
|
||||
public const int MaxLength = 256;
|
||||
|
||||
public static LocalReturnPath Default { get; } = new("/App");
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public static LocalReturnPath Parse(string? candidate)
|
||||
{
|
||||
if (string.IsNullOrEmpty(candidate) ||
|
||||
candidate.Length > MaxLength ||
|
||||
candidate[0] != '/' ||
|
||||
candidate.Any(char.IsControl) ||
|
||||
(candidate.Length > 1 && (candidate[1] == '/' || candidate[1] == '\\')))
|
||||
{
|
||||
return Default;
|
||||
}
|
||||
|
||||
return new LocalReturnPath(candidate);
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
|
|
@ -125,12 +125,12 @@ builder.Services.AddScoped<ReadinessService>();
|
|||
builder.Services.AddScoped<ApplicationBootstrapper>();
|
||||
builder.Services.AddSingleton<StartupConfigurationValidator>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
|
||||
builder.Services.AddSingleton<HeimdallAuthAttemptStore>();
|
||||
builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/'));
|
||||
});
|
||||
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
|
||||
.RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase));
|
||||
builder.Services.AddSingleton<HeimdallTokenVerifier>();
|
||||
builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) =>
|
||||
HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value));
|
||||
builder.Services.AddHttpClient<StripeCheckoutService>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.stripe.com/");
|
||||
|
|
@ -300,22 +300,18 @@ app.MapPost("/github/webhooks", async (
|
|||
|
||||
app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuthOptions> gitHubOptions) =>
|
||||
{
|
||||
var returnUrl = httpContext.Request.Query["returnUrl"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(returnUrl))
|
||||
{
|
||||
returnUrl = "/App";
|
||||
}
|
||||
var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString());
|
||||
|
||||
var provider = httpContext.Request.Query["provider"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
await WriteSignInChooserAsync(httpContext, returnUrl);
|
||||
await WriteSignInChooserAsync(httpContext, returnPath.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!provider.Equals("github", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||
httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnPath.Value)}");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -327,7 +323,7 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuth
|
|||
|
||||
await httpContext.ChallengeAsync(
|
||||
GitHubAuthenticationDefaults.AuthenticationScheme,
|
||||
new() { RedirectUri = returnUrl });
|
||||
new() { RedirectUri = returnPath.Value });
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapPost("/auth/bifrost/register", async (
|
||||
|
|
@ -367,16 +363,12 @@ app.MapGet("/auth/heimdall/{provider}", async (
|
|||
HttpContext httpContext,
|
||||
HeimdallAuthService heimdallAuthService,
|
||||
IOptions<BifrostHostOptions> hostOptions,
|
||||
ILogger<HeimdallAuthService> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var returnUrl = httpContext.Request.Query["returnUrl"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(returnUrl))
|
||||
{
|
||||
returnUrl = "/App";
|
||||
}
|
||||
|
||||
var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString());
|
||||
var requireMemberAccess = string.Equals(
|
||||
httpContext.Request.Query["access"].ToString(),
|
||||
"member",
|
||||
|
|
@ -385,30 +377,18 @@ app.MapGet("/auth/heimdall/{provider}", async (
|
|||
var start = await heimdallAuthService.StartAsync(
|
||||
provider,
|
||||
hostOptions.Value.PublicBaseUrl,
|
||||
returnUrl,
|
||||
returnPath,
|
||||
requireMemberAccess,
|
||||
httpContext.Request.Cookies,
|
||||
cancellationToken);
|
||||
|
||||
httpContext.Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options());
|
||||
return Results.Redirect(start.AuthorizationUrl.ToString());
|
||||
}
|
||||
catch (HeimdallAuthException error)
|
||||
{
|
||||
return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}");
|
||||
}
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapPost("/auth/heimdall/callback", (
|
||||
HeimdallBackendCallbackPayload payload,
|
||||
HeimdallAuthService heimdallAuthService) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
heimdallAuthService.StoreBackendCallback(payload);
|
||||
return Results.Accepted();
|
||||
}
|
||||
catch (HeimdallAuthException error)
|
||||
{
|
||||
return Results.BadRequest(error.Message);
|
||||
logger.LogWarning("Heimdall start refused: {Reason}", error.Message);
|
||||
return Results.Redirect(HeimdallAuthService.ErrorRedirect);
|
||||
}
|
||||
}).AllowAnonymous();
|
||||
|
||||
|
|
@ -534,31 +514,44 @@ eveGovernance.MapGet("/surface", async (
|
|||
"application/json");
|
||||
});
|
||||
|
||||
app.MapGet("/auth/heimdall/wait", async (
|
||||
app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) =>
|
||||
{
|
||||
httpContext.Response.Headers.CacheControl = "no-store";
|
||||
httpContext.Response.Headers["Referrer-Policy"] = "no-referrer";
|
||||
return Results.Content(HeimdallCompletePage.Html, "text/html", System.Text.Encoding.UTF8);
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapPost("/auth/heimdall/complete", async (
|
||||
HttpContext httpContext,
|
||||
HeimdallAuthService heimdallAuthService,
|
||||
IOptions<BifrostHostOptions> hostOptions,
|
||||
ILogger<HeimdallAuthService> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var attemptId = httpContext.Request.Query["attemptId"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(attemptId))
|
||||
{
|
||||
return Results.Redirect("/?auth=heimdall-error&detail=missing-attempt-id");
|
||||
}
|
||||
|
||||
var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken);
|
||||
var attempt = form?["attempt"].ToString();
|
||||
var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt);
|
||||
try
|
||||
{
|
||||
var signIn = await heimdallAuthService.CompleteAttemptAsync(attemptId, cancellationToken);
|
||||
var signIn = await heimdallAuthService.CompleteAsync(
|
||||
hostOptions.Value.PublicBaseUrl,
|
||||
attempt,
|
||||
held?.Value,
|
||||
form?["code"].ToString(),
|
||||
cancellationToken);
|
||||
await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal);
|
||||
return Results.Redirect(signIn.RedirectUri);
|
||||
}
|
||||
catch (HeimdallAuthPendingException)
|
||||
{
|
||||
httpContext.Response.Headers.Append("Refresh", "2");
|
||||
return Results.Text("Waiting for Heimdall sign-in to complete.", "text/plain");
|
||||
httpContext.Response.Cookies.Delete(held!.Value.Name, HeimdallAttemptCookie.DeleteOptions());
|
||||
return Results.LocalRedirect(signIn.ReturnPath.Value);
|
||||
}
|
||||
catch (HeimdallAuthException error)
|
||||
{
|
||||
return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}");
|
||||
logger.LogWarning("Heimdall completion refused: {Reason}", error.Message);
|
||||
if (held is not null)
|
||||
{
|
||||
httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions());
|
||||
}
|
||||
|
||||
return Results.Redirect(HeimdallAuthService.ErrorRedirect);
|
||||
}
|
||||
}).AllowAnonymous();
|
||||
|
||||
|
|
@ -578,6 +571,23 @@ static IResult JsonResult<T>(T value, JsonSerializerOptions options, int statusC
|
|||
"application/json",
|
||||
statusCode: statusCode);
|
||||
|
||||
static async Task<IFormCollection?> ReadFormOrNullAsync(HttpRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!request.HasFormContentType)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await request.ReadFormAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception error) when (error is InvalidDataException or BadHttpRequestException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl)
|
||||
{
|
||||
httpContext.Response.ContentType = "text/html; charset=utf-8";
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"Heimdall": {
|
||||
"BaseUrl": "https://heimdall.gamecult.org",
|
||||
"AppSlug": "bifrost",
|
||||
"AppSecret": "",
|
||||
"DiscordGuildId": "",
|
||||
"DiscordAllowedRoleIds": [],
|
||||
"PatreonTierTitle": "Inner Sanctum",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="Fixtures\*.cjs" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
64
tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs
Normal file
64
tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Runs the complete page's real inline script against a simulated browser, once per scenario, and prints
|
||||
// what it did as JSON: the ordered events, and the address and form values at the moment of the submit.
|
||||
// Usage: node complete-page-harness.cjs <page.html>
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
|
||||
const html = fs.readFileSync(process.argv[2], "utf8");
|
||||
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||
if (scripts.length !== 1) {
|
||||
throw new Error(`expected exactly one inline script, found ${scripts.length}`);
|
||||
}
|
||||
const script = scripts[0][1];
|
||||
|
||||
function run(name, search, hash) {
|
||||
const events = [];
|
||||
let submit = null;
|
||||
const location = {
|
||||
pathname: "/auth/heimdall/complete",
|
||||
search,
|
||||
hash,
|
||||
get href() {
|
||||
return this.pathname + this.search + this.hash;
|
||||
},
|
||||
replace(url) {
|
||||
events.push(`replace:${url}`);
|
||||
},
|
||||
};
|
||||
const history = {
|
||||
replaceState(_state, _title, url) {
|
||||
events.push(`replaceState:${url}`);
|
||||
const next = new URL(url, "https://bifrost.test" + location.href);
|
||||
location.pathname = next.pathname;
|
||||
location.search = next.search;
|
||||
location.hash = next.hash;
|
||||
},
|
||||
};
|
||||
const form = {
|
||||
elements: { attempt: { value: "" }, code: { value: "" } },
|
||||
submit() {
|
||||
events.push("submit");
|
||||
submit = {
|
||||
attempt: form.elements.attempt.value,
|
||||
code: form.elements.code.value,
|
||||
href: location.href,
|
||||
hash: location.hash,
|
||||
};
|
||||
},
|
||||
};
|
||||
const document = { getElementById: (id) => (id === "complete" ? form : null) };
|
||||
vm.runInNewContext(script, { URLSearchParams, location, history, document });
|
||||
return { name, events, submit };
|
||||
}
|
||||
|
||||
const complete = "&heimdall_completion_code=CODE123";
|
||||
console.log(
|
||||
JSON.stringify([
|
||||
run("success", "?attempt=NONCE", "#heimdall_status=success" + complete),
|
||||
run("success-with-decoy-query-code", "?attempt=NONCE&heimdall_completion_code=QCODE", "#heimdall_status=success" + complete),
|
||||
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("code-in-query-only", "?attempt=NONCE&heimdall_completion_code=QCODE", ""),
|
||||
]),
|
||||
);
|
||||
107
tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs
Normal file
107
tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the complete page's inline script, as the server serves it, under Node against a simulated browser
|
||||
/// (Fixtures/complete-page-harness.cjs). Node is required: a missing Node fails these tests, it does not skip them.
|
||||
/// </summary>
|
||||
public sealed class HeimdallCompletePageScriptTests
|
||||
{
|
||||
private const string ErrorTarget = "replace:/?auth=heimdall-error";
|
||||
|
||||
private sealed record Run(string Name, string[] Events, Submit? Submit);
|
||||
|
||||
private sealed record Submit(string Attempt, string Code, string Href, string Hash);
|
||||
|
||||
private static async Task<Dictionary<string, Run>> RunScenariosAsync()
|
||||
{
|
||||
using var host = new HeimdallHost();
|
||||
using var client = host.NewClient();
|
||||
var page = await client.GetStringAsync("/auth/heimdall/complete");
|
||||
var pagePath = Path.Combine(Path.GetTempPath(), $"bifrost-complete-{Guid.NewGuid():N}.html");
|
||||
await File.WriteAllTextAsync(pagePath, page);
|
||||
try
|
||||
{
|
||||
var harness = Path.Combine(AppContext.BaseDirectory, "Fixtures", "complete-page-harness.cjs");
|
||||
var start = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
start.ArgumentList.Add(harness);
|
||||
start.ArgumentList.Add(pagePath);
|
||||
Process process;
|
||||
try
|
||||
{
|
||||
process = Process.Start(start)!;
|
||||
}
|
||||
catch (Win32Exception error)
|
||||
{
|
||||
throw new InvalidOperationException("Node must be on PATH to run the complete page's script: " + error.Message, error);
|
||||
}
|
||||
|
||||
using (process)
|
||||
{
|
||||
var output = process.StandardOutput.ReadToEndAsync();
|
||||
var errors = process.StandardError.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, await errors);
|
||||
var runs = JsonSerializer.Deserialize<Run[]>(await output, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
return runs.ToDictionary(x => x.Name);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(pagePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_fragment_is_dropped_from_the_address_before_the_code_is_posted()
|
||||
{
|
||||
var runs = await RunScenariosAsync();
|
||||
|
||||
foreach (var name in new[] { "success", "success-with-decoy-query-code" })
|
||||
{
|
||||
var run = runs[name];
|
||||
Assert.Equal(2, run.Events.Length);
|
||||
Assert.StartsWith("replaceState:/auth/heimdall/complete?attempt=NONCE", run.Events[0]);
|
||||
Assert.DoesNotContain("#", run.Events[0]);
|
||||
Assert.DoesNotContain("CODE123", run.Events[0]);
|
||||
Assert.Equal("submit", run.Events[1]);
|
||||
Assert.Equal("", run.Submit!.Hash);
|
||||
Assert.DoesNotContain("CODE123", run.Submit.Href);
|
||||
Assert.DoesNotContain("#", run.Submit.Href);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_code_is_read_only_from_the_fragment()
|
||||
{
|
||||
var runs = await RunScenariosAsync();
|
||||
|
||||
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"];
|
||||
Assert.Null(queryOnly.Submit);
|
||||
Assert.DoesNotContain("submit", queryOnly.Events);
|
||||
Assert.Equal(ErrorTarget, queryOnly.Events[^1]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("error-status-with-code")]
|
||||
[InlineData("no-code")]
|
||||
[InlineData("no-attempt")]
|
||||
public async Task A_failed_or_incomplete_handoff_never_submits_and_leaves_through_the_error_redirect(string scenario)
|
||||
{
|
||||
var run = (await RunScenariosAsync())[scenario];
|
||||
|
||||
Assert.Null(run.Submit);
|
||||
Assert.DoesNotContain("submit", run.Events);
|
||||
Assert.Equal(ErrorTarget, run.Events[^1]);
|
||||
Assert.Contains(run.Events, x => x.StartsWith("replaceState:", StringComparison.Ordinal) && !x.Contains('#'));
|
||||
Assert.DoesNotContain("CODE123", string.Join("\n", run.Events));
|
||||
}
|
||||
}
|
||||
1411
tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs
Normal file
1411
tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs
Normal file
File diff suppressed because it is too large
Load diff
521
tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs
Normal file
521
tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Features.Heimdall;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Org.BouncyCastle.Math.EC.Rfc8032;
|
||||
|
||||
namespace Bifrost.Web.Tests.Support;
|
||||
|
||||
/// <summary>An Ed25519 key shaped like Heimdall's signing key: a "ed25519-" kid over a thumbprint prefix.</summary>
|
||||
public sealed class HeimdallKey
|
||||
{
|
||||
private readonly byte[] _seed;
|
||||
|
||||
private HeimdallKey(byte[] seed, byte[] publicKey, string kid)
|
||||
{
|
||||
_seed = seed;
|
||||
PublicKey = publicKey;
|
||||
Kid = kid;
|
||||
}
|
||||
|
||||
public string Kid { get; }
|
||||
|
||||
public byte[] PublicKey { get; }
|
||||
|
||||
public static HeimdallKey Generate(string? kid = null)
|
||||
{
|
||||
var seed = RandomNumberGenerator.GetBytes(Ed25519.SecretKeySize);
|
||||
var publicKey = new byte[Ed25519.PublicKeySize];
|
||||
Ed25519.GeneratePublicKey(seed, 0, publicKey, 0);
|
||||
kid ??= "ed25519-" + Convert.ToHexString(SHA256.HashData(publicKey)).ToLowerInvariant()[..12];
|
||||
return new HeimdallKey(seed, publicKey, kid);
|
||||
}
|
||||
|
||||
public object Jwk() => new Dictionary<string, object?>
|
||||
{
|
||||
["kty"] = "OKP",
|
||||
["crv"] = "Ed25519",
|
||||
["x"] = WebEncoders.Base64UrlEncode(PublicKey),
|
||||
["alg"] = "EdDSA",
|
||||
["kid"] = Kid,
|
||||
["use"] = "sig"
|
||||
};
|
||||
|
||||
/// <summary>Signs like Heimdall's signJwt: header {alg, kid, typ}, compact JWS.</summary>
|
||||
public string Sign(IDictionary<string, object?> claims, string alg = "EdDSA", string? kid = "") =>
|
||||
SignParts(HeaderBytes(alg, kid), JsonSerializer.SerializeToUtf8Bytes(claims));
|
||||
|
||||
public byte[] HeaderBytes(string alg = "EdDSA", string? kid = "")
|
||||
{
|
||||
var header = new Dictionary<string, object?> { ["alg"] = alg, ["typ"] = "JWT" };
|
||||
if (kid is not null)
|
||||
{
|
||||
header["kid"] = kid.Length == 0 ? Kid : kid;
|
||||
}
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(header);
|
||||
}
|
||||
|
||||
/// <summary>Signs exactly these header and payload bytes, whatever they hold.</summary>
|
||||
public string SignParts(byte[] headerBytes, byte[] payloadBytes)
|
||||
{
|
||||
var signingInput = WebEncoders.Base64UrlEncode(headerBytes) + "." + WebEncoders.Base64UrlEncode(payloadBytes);
|
||||
var message = Encoding.ASCII.GetBytes(signingInput);
|
||||
var signature = new byte[Ed25519.SignatureSize];
|
||||
Ed25519.Sign(_seed, 0, message, 0, message.Length, signature, 0);
|
||||
return signingInput + "." + WebEncoders.Base64UrlEncode(signature);
|
||||
}
|
||||
}
|
||||
|
||||
public static class HeimdallShapes
|
||||
{
|
||||
public const string Issuer = "https://heimdall.test";
|
||||
|
||||
public static string NewAccountId() => Guid.NewGuid().ToString();
|
||||
|
||||
/// <summary>The claim set of Heimdall src/claims.ts issueAccessClaim.</summary>
|
||||
public static Dictionary<string, object?> AccessClaims(
|
||||
string accountId,
|
||||
DateTimeOffset now,
|
||||
string[]? capabilities = null,
|
||||
string? displayName = "Genuine Name",
|
||||
Action<Dictionary<string, object?>>? mutate = null)
|
||||
{
|
||||
var issuedAt = now.ToUnixTimeSeconds();
|
||||
var claims = new Dictionary<string, object?>
|
||||
{
|
||||
["iss"] = Issuer,
|
||||
["aud"] = "bifrost",
|
||||
["sub"] = accountId,
|
||||
["sid"] = Guid.NewGuid().ToString(),
|
||||
["jti"] = Guid.NewGuid().ToString(),
|
||||
["iat"] = issuedAt,
|
||||
["nbf"] = issuedAt,
|
||||
["exp"] = issuedAt + 900,
|
||||
["typ"] = "heimdall_access",
|
||||
["account_id"] = accountId,
|
||||
["access_revision"] = 1,
|
||||
["app"] = new Dictionary<string, object?> { ["slug"] = "bifrost", ["profile_version"] = 1 },
|
||||
["facts"] = Array.Empty<string>(),
|
||||
["capabilities"] = capabilities ?? [],
|
||||
["identities"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?> { ["provider"] = "discord", ["providerUserId"] = "1234567890" }
|
||||
}
|
||||
};
|
||||
if (displayName is not null)
|
||||
{
|
||||
claims["display_name"] = displayName;
|
||||
}
|
||||
|
||||
mutate?.Invoke(claims);
|
||||
return claims;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The body Heimdall's redeem route answers with: the completionPayload of src/app.ts, whose unsigned
|
||||
/// fields (loose capabilities, facts, name) Bifrost must not decide anything from.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> Completion(
|
||||
string returnTo,
|
||||
string accountId,
|
||||
string accessToken,
|
||||
string[]? looseCapabilities = null,
|
||||
string[]? looseFacts = null,
|
||||
string looseName = "Loose Name") => new()
|
||||
{
|
||||
["status"] = "success",
|
||||
["provider"] = "discord",
|
||||
["mode"] = "sign_in",
|
||||
["appSlug"] = "bifrost",
|
||||
["account"] = new Dictionary<string, object?> { ["id"] = accountId, ["displayName"] = looseName },
|
||||
["entitlements"] = new Dictionary<string, object?>
|
||||
{
|
||||
["facts"] = looseFacts ?? [],
|
||||
["snapshots"] = Array.Empty<string>()
|
||||
},
|
||||
["returnTo"] = returnTo,
|
||||
["connection"] = null,
|
||||
["session"] = new Dictionary<string, object?> { ["accountId"] = accountId, ["appSlug"] = "bifrost" },
|
||||
["accessToken"] = accessToken,
|
||||
["refreshToken"] = "refresh-token-not-read",
|
||||
["refresh"] = new Dictionary<string, object?> { ["expiresAt"] = "2026-11-11T00:00:00.000Z" },
|
||||
["verification"] = new Dictionary<string, object?> { ["issuer"] = Issuer },
|
||||
["sharedCapabilities"] = looseCapabilities ?? [],
|
||||
["hybridCapabilities"] = Array.Empty<string>()
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class TestClock : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public DateTimeOffset Now => _now;
|
||||
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for Heimdall's HTTP surface: the JWKS, the oauth start endpoint (which, like app.ts, answers
|
||||
/// a policy start only when the app caller's secret is present) and the completion-code redeem route.
|
||||
/// </summary>
|
||||
public sealed class FakeHeimdall : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<string, Dictionary<string, object?>> _codes = new(StringComparer.Ordinal);
|
||||
|
||||
public List<HeimdallKey> Published { get; } = [];
|
||||
|
||||
public List<object> ExtraJwks { get; } = [];
|
||||
|
||||
public int JwksFetches { get; private set; }
|
||||
|
||||
public HttpStatusCode JwksStatus { get; set; } = HttpStatusCode.OK;
|
||||
|
||||
private TaskCompletionSource? _jwksHold;
|
||||
|
||||
/// <summary>Completes when a JWKS request arrives after <see cref="HoldJwks"/>.</summary>
|
||||
public TaskCompletionSource JwksArrived { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Holds the next JWKS responses until the returned source completes.</summary>
|
||||
public TaskCompletionSource HoldJwks()
|
||||
{
|
||||
JwksArrived = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
return _jwksHold = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
public Exception? JwksFailure { get; set; }
|
||||
|
||||
public List<string> StartBodies { get; } = [];
|
||||
|
||||
public List<string?> StartSecrets { get; } = [];
|
||||
|
||||
/// <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; }
|
||||
|
||||
public HttpStatusCode StartStatus { get; set; } = HttpStatusCode.OK;
|
||||
|
||||
public string StartBody { get; set; } =
|
||||
"""{"authorizationUrl":"https://heimdall.test/oauth/discord/authorize?state=fixture"}""";
|
||||
|
||||
public int RedeemCalls { get; private set; }
|
||||
|
||||
public HttpStatusCode? RedeemStatus { get; set; }
|
||||
|
||||
public string? RedeemBody { get; set; }
|
||||
|
||||
/// <summary>Registers a completion code Heimdall will redeem once, answering with this payload.</summary>
|
||||
public string RegisterCode(Dictionary<string, object?> completion)
|
||||
{
|
||||
var code = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||
_codes[code] = completion;
|
||||
return code;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = request.RequestUri!.AbsolutePath;
|
||||
if (request.Method == HttpMethod.Get && path == "/.well-known/jwks.json")
|
||||
{
|
||||
JwksFetches++;
|
||||
JwksArrived.TrySetResult();
|
||||
if (_jwksHold is not null)
|
||||
{
|
||||
await _jwksHold.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (JwksFailure is not null)
|
||||
{
|
||||
throw JwksFailure;
|
||||
}
|
||||
|
||||
var keys = Published.Select(x => x.Jwk()).Concat(ExtraJwks).ToArray();
|
||||
return new HttpResponseMessage(JwksStatus)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(new { keys }), Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Post && path.StartsWith("/v1/oauth/", StringComparison.Ordinal) && path.EndsWith("/start", StringComparison.Ordinal))
|
||||
{
|
||||
var body = await request.Content!.ReadAsStringAsync(cancellationToken);
|
||||
var secret = request.Headers.TryGetValues("x-heimdall-app-secret", out var values) ? values.Single() : null;
|
||||
StartBodies.Add(body);
|
||||
StartSecrets.Add(secret);
|
||||
var wantsPolicy = JsonDocument.Parse(body).RootElement.TryGetProperty("entitlementPolicy", out _);
|
||||
if (wantsPolicy && RequiredAppSecret is not null && secret != RequiredAppSecret)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.Unauthorized)
|
||||
{
|
||||
Content = new StringContent("""{"error":"app_auth_required"}""", Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(StartStatus)
|
||||
{
|
||||
Content = new StringContent(StartBody, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/auth-completions/redeem")
|
||||
{
|
||||
RedeemCalls++;
|
||||
if (RedeemStatus is { } status)
|
||||
{
|
||||
return new HttpResponseMessage(status)
|
||||
{
|
||||
Content = new StringContent(RedeemBody ?? "{}", Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
var code = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken))
|
||||
.RootElement.GetProperty("completionCode").GetString()!;
|
||||
if (!_codes.Remove(code, out var completion))
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.Gone)
|
||||
{
|
||||
Content = new StringContent("""{"error":"invalid_or_expired_completion_code"}""", Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.Created)
|
||||
{
|
||||
Content = new StringContent(RedeemBody ?? JsonSerializer.Serialize(completion), Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CapturedLogs : ILoggerProvider
|
||||
{
|
||||
private readonly List<string> _lines = [];
|
||||
|
||||
public IReadOnlyList<string> Lines
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lines)
|
||||
{
|
||||
return _lines.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new Capture(this);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class Capture(CapturedLogs owner) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (owner._lines)
|
||||
{
|
||||
owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An attempt Start issued: its nonce, the cookie it set, and the returnTo it sent Heimdall.</summary>
|
||||
public sealed record StartedAttempt(string Nonce, string? CookieName, string? Cookie, string ReturnTo, HttpResponseMessage Response);
|
||||
|
||||
public sealed record CompletionResult(HttpResponseMessage Response, bool SignedIn);
|
||||
|
||||
/// <summary>A local Bifrost with Heimdall faked in-process: a test JWKS, a test clock, captured logs.</summary>
|
||||
public sealed class HeimdallHost : TestWebApplicationFactory
|
||||
{
|
||||
public const string AppSecret = "test-heimdall-app-secret";
|
||||
|
||||
private readonly string _appSecret;
|
||||
private readonly string _appSlug;
|
||||
private readonly string _baseUrl;
|
||||
private readonly string _publicBaseUrl;
|
||||
private readonly bool _discordRoles;
|
||||
|
||||
public TestClock Clock { get; } = new();
|
||||
|
||||
public FakeHeimdall Heimdall { get; } = new();
|
||||
|
||||
public CapturedLogs Logs { get; } = new();
|
||||
|
||||
public HeimdallKey Key { get; } = HeimdallKey.Generate();
|
||||
|
||||
public HeimdallHost(
|
||||
string appSecret = AppSecret,
|
||||
string baseUrl = HeimdallShapes.Issuer,
|
||||
string publicBaseUrl = "https://localhost",
|
||||
bool discordRoles = true,
|
||||
string appSlug = "bifrost")
|
||||
{
|
||||
_appSecret = appSecret;
|
||||
_appSlug = appSlug;
|
||||
_baseUrl = baseUrl;
|
||||
_publicBaseUrl = publicBaseUrl;
|
||||
_discordRoles = discordRoles;
|
||||
Heimdall.Published.Add(Key);
|
||||
Heimdall.RequiredAppSecret = AppSecret;
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
// The GitHub OAuth scheme is registered from configuration read while the app builds, which only host settings reach.
|
||||
builder.UseSetting("GitHubOAuth:ClientId", "configured-for-tests");
|
||||
builder.UseSetting("GitHubOAuth:ClientSecret", "configured-for-tests");
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Heimdall:BaseUrl"] = _baseUrl,
|
||||
["Heimdall:AppSecret"] = _appSecret,
|
||||
["Heimdall:AppSlug"] = _appSlug,
|
||||
["Heimdall:DiscordGuildId"] = "guild-1",
|
||||
["Host:PublicBaseUrl"] = _publicBaseUrl
|
||||
});
|
||||
if (_discordRoles)
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Heimdall:DiscordAllowedRoleIds:0"] = "role-1" });
|
||||
}
|
||||
});
|
||||
builder.ConfigureLogging(logging => logging.AddProvider(Logs));
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<TimeProvider>();
|
||||
services.AddSingleton<TimeProvider>(Clock);
|
||||
services.AddHttpClient(HeimdallTokenVerifier.ClientName).ConfigurePrimaryHttpMessageHandler(() => Heimdall);
|
||||
services.AddHttpClient<HeimdallAuthService>().ConfigurePrimaryHttpMessageHandler(() => Heimdall);
|
||||
});
|
||||
}
|
||||
|
||||
public HttpClient NewClient() => CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
HandleCookies = false,
|
||||
BaseAddress = new Uri("https://localhost")
|
||||
});
|
||||
|
||||
public string Token(string accountId, string[]? capabilities = null, string? displayName = "Genuine Name",
|
||||
Action<Dictionary<string, object?>>? mutate = null, HeimdallKey? signer = null) =>
|
||||
(signer ?? Key).Sign(HeimdallShapes.AccessClaims(accountId, Clock.Now, capabilities, displayName, mutate));
|
||||
|
||||
public Task<StartedAttempt> StartAsync(
|
||||
bool member = false, string provider = "discord", string returnUrl = "/App/Mill", string? cookieHeader = null) =>
|
||||
StartAtAsync(
|
||||
$"/auth/heimdall/{provider}?returnUrl={Uri.EscapeDataString(returnUrl)}" + (member ? "&access=member" : string.Empty),
|
||||
cookieHeader);
|
||||
|
||||
public async Task<StartedAttempt> StartAtAsync(string url, string? cookieHeader = null)
|
||||
{
|
||||
using var client = NewClient();
|
||||
var before = Heimdall.StartBodies.Count;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
if (cookieHeader is not null)
|
||||
{
|
||||
request.Headers.Add("Cookie", cookieHeader);
|
||||
}
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
if (Heimdall.StartBodies.Count == before)
|
||||
{
|
||||
return new StartedAttempt(string.Empty, null, null, string.Empty, response);
|
||||
}
|
||||
|
||||
var returnTo = JsonDocument.Parse(Heimdall.StartBodies[^1]).RootElement.GetProperty("returnTo").GetString()!;
|
||||
var nonce = QueryHelpers.ParseQuery(new Uri(returnTo).Query)["attempt"].ToString();
|
||||
var slot = HeimdallAttemptCookie.SlotNames.FirstOrDefault(x => CookieValue(response, x) is { Length: > 0 });
|
||||
return new StartedAttempt(nonce, slot, slot is null ? null : CookieValue(response, slot), returnTo, response);
|
||||
}
|
||||
|
||||
/// <summary>Registers a completion code as Heimdall would mint it after authenticating this attempt.</summary>
|
||||
public string CodeFor(StartedAttempt attempt, string accountId, string? accessToken = null, string[]? looseCapabilities = null,
|
||||
string[]? looseFacts = null, string looseName = "Loose Name", string? returnTo = null)
|
||||
{
|
||||
var completion = HeimdallShapes.Completion(
|
||||
returnTo ?? attempt.ReturnTo, accountId, accessToken ?? Token(accountId), looseCapabilities, looseFacts, looseName);
|
||||
return Heimdall.RegisterCode(completion);
|
||||
}
|
||||
|
||||
/// <summary>The browser's POST from the complete page: the attempt and code in a form, the attempt cookie by name.</summary>
|
||||
public Task<CompletionResult> CompleteAsync(string? nonce, string? code, string? cookie, string? cookieName = null) =>
|
||||
CompleteWithHeaderAsync(
|
||||
nonce, code, cookie is null ? null : $"{cookieName ?? HeimdallAttemptCookie.SlotNames[0]}={cookie}");
|
||||
|
||||
/// <summary>The same POST carrying a browser's whole Cookie header (a jar), not one attempt cookie.</summary>
|
||||
public async Task<CompletionResult> CompleteWithHeaderAsync(string? nonce, string? code, string? cookieHeader)
|
||||
{
|
||||
using var client = NewClient();
|
||||
var fields = new Dictionary<string, string>();
|
||||
if (nonce is not null)
|
||||
{
|
||||
fields["attempt"] = nonce;
|
||||
}
|
||||
|
||||
if (code is not null)
|
||||
{
|
||||
fields["code"] = code;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/heimdall/complete") { Content = new FormUrlEncodedContent(fields) };
|
||||
if (cookieHeader is not null)
|
||||
{
|
||||
request.Headers.Add("Cookie", cookieHeader);
|
||||
}
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var session = CookieValue(response, ".AspNetCore.Cookies");
|
||||
return new CompletionResult(response, !string.IsNullOrEmpty(session));
|
||||
}
|
||||
|
||||
public Task<CompletionResult> CompleteAsync(StartedAttempt attempt, string code) =>
|
||||
CompleteAsync(attempt.Nonce, code, attempt.Cookie, attempt.CookieName);
|
||||
|
||||
public static string? CookieValue(HttpResponseMessage response, string name)
|
||||
{
|
||||
if (!response.Headers.TryGetValues("Set-Cookie", out var headers))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var prefix = name + "=";
|
||||
var header = headers.FirstOrDefault(x => x.StartsWith(prefix, StringComparison.Ordinal));
|
||||
return header?[prefix.Length..].Split(';')[0];
|
||||
}
|
||||
|
||||
public static string? SetCookieHeader(HttpResponseMessage response, string name) =>
|
||||
response.Headers.TryGetValues("Set-Cookie", out var headers)
|
||||
? headers.FirstOrDefault(x => x.StartsWith(name + "=", StringComparison.Ordinal))
|
||||
: null;
|
||||
|
||||
public async Task<T> Db<T>(Func<BifrostDbContext, Task<T>> query)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
return await query(scope.ServiceProvider.GetRequiredService<BifrostDbContext>());
|
||||
}
|
||||
|
||||
public Task<Bifrost.Web.Domain.UserAccount?> Account(string heimdallAccountId) =>
|
||||
Db(db => db.UserAccounts
|
||||
.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments)
|
||||
.SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId));
|
||||
|
||||
public Task<int> AccountCount() => Db(db => db.UserAccounts.CountAsync());
|
||||
}
|
||||
|
|
@ -16,7 +16,8 @@ namespace Bifrost.Web.Tests.Support;
|
|||
|
||||
public class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly InMemoryDatabaseRoot _databaseRoot = new();
|
||||
// One root for every factory: EF caches a service provider per root, and warns past twenty.
|
||||
private static readonly InMemoryDatabaseRoot _databaseRoot = new();
|
||||
private readonly string _databaseName = $"bifrost-tests-{Guid.NewGuid():N}";
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
|
|
|
|||
Loading…
Reference in a new issue