diff --git a/src/Bifrost.Web/Configuration/HeimdallOptions.cs b/src/Bifrost.Web/Configuration/HeimdallOptions.cs index adddfdc..01eaec9 100644 --- a/src/Bifrost.Web/Configuration/HeimdallOptions.cs +++ b/src/Bifrost.Web/Configuration/HeimdallOptions.cs @@ -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 => diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index d27a3fe..2491b8c 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -1,28 +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; +/// +/// 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. +/// public sealed class HeimdallAuthService( HttpClient httpClient, - HeimdallAuthAttemptStore attemptStore, HeimdallTokenVerifier tokenVerifier, BifrostDbContext dbContext, BifrostIdentityService bifrostIdentityService, IOptions heimdallOptions, + IDataProtectionProvider dataProtectionProvider, TimeProvider timeProvider) { + public const string ErrorRedirect = "/?auth=heimdall-error"; + public const string AppSecretHeader = "x-heimdall-app-secret"; + private static readonly HashSet AllowedProviders = new(StringComparer.OrdinalIgnoreCase) { "discord", @@ -30,10 +41,22 @@ public sealed class HeimdallAuthService( "twitch" }; + private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Bifrost.Heimdall.Attempt.v1"); + + /// Points the client at Heimdall and, when the app has a secret, identifies Bifrost as the app. + 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 StartAsync( string provider, string callbackBaseUrl, - string returnUrl, + LocalReturnPath returnPath, bool requireMemberAccess, CancellationToken cancellationToken) { @@ -51,14 +74,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( @@ -77,35 +99,41 @@ public sealed class HeimdallAuthService( throw new HeimdallAuthException("Heimdall auth start returned no authorization URL."); } - var binding = attemptStore.Create(attemptId, provider, returnUrl, requireMemberAccess); - return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), attemptId, binding); + var attempt = new HeimdallAttempt( + nonce, + provider, + requireMemberAccess, + returnPath, + timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime); + return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), nonce, Protect(attempt)); } - public async Task StoreBackendCallbackAsync( - HeimdallBackendCallbackPayload payload, + public async Task CompleteAsync( + string callbackBaseUrl, + string? nonce, + string? cookieValue, + string? code, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(payload.AttemptId)) + var attempt = Unprotect(cookieValue, nonce) + ?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser."); + if (string.IsNullOrWhiteSpace(code)) { - throw new HeimdallAuthException("Heimdall callback omitted attempt id."); + throw new HeimdallAuthException("Heimdall completion carried no code."); } - if (string.IsNullOrWhiteSpace(payload.AccessToken)) + var redeemed = await RedeemAsync(code, cancellationToken); + if (!string.Equals(redeemed.ReturnTo, ToAttemptReturnUrl(callbackBaseUrl, attempt.Nonce), StringComparison.Ordinal)) { - throw new HeimdallAuthException("Heimdall callback carried no access token."); + throw new HeimdallAuthException("Heimdall completion belongs to another attempt."); } - var identity = await tokenVerifier.VerifyAsync(payload.AccessToken, payload.Account?.Id, cancellationToken); - attemptStore.Complete(payload.AttemptId, identity); - } + if (string.IsNullOrWhiteSpace(redeemed.AccessToken)) + { + throw new HeimdallAuthException("Heimdall completion carried no access token."); + } - public async Task CompleteAttemptAsync( - string attemptId, - string? binding, - CancellationToken cancellationToken) - { - var attempt = attemptStore.TryTake(attemptId, binding); - var identity = attempt.Identity; + 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."); @@ -118,9 +146,68 @@ public sealed class HeimdallAuthService( cancellationToken); return new HeimdallCompletedSignIn( bifrostIdentityService.BuildPrincipal(userAccount, attempt.Provider), - attempt.ReturnUrl); + attempt.ReturnPath); } + private async Task 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(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))); + + private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) + { + if (string.IsNullOrEmpty(cookieValue) || string.IsNullOrEmpty(nonce)) + { + return null; + } + + try + { + var wire = JsonSerializer.Deserialize(_protector.Unprotect(cookieValue)); + if (wire is null || + !string.Equals(wire.Nonce, nonce, StringComparison.Ordinal) || + 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 UpsertUserAccountAsync( HeimdallVerifiedIdentity identity, string provider, @@ -248,112 +335,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 Nonce, string CookieValue); -public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string AttemptId, string Binding); +public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, LocalReturnPath ReturnPath); -public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, string RedirectUri); - -/// -/// Attempts Start issued. An attempt lives for , is completed only by a verified -/// Heimdall identity, and is redeemed once by the browser that holds its binding value. The store holds at -/// most attempts; a full store drops the one that expires first. -/// -public sealed class HeimdallAuthAttemptStore(TimeProvider timeProvider) -{ - public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10); - public const int Capacity = 1024; - - private readonly object _gate = new(); - private readonly Dictionary _attempts = new(StringComparer.Ordinal); - - public string Create(string attemptId, string provider, string returnUrl, bool requiresMemberAccess) - { - var now = timeProvider.GetUtcNow(); - var binding = Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlEncode( - System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); - lock (_gate) - { - while (_attempts.Count >= Capacity) - { - _attempts.Remove(_attempts.MinBy(x => x.Value.ExpiresAt).Key); - } - - _attempts[attemptId] = new HeimdallAuthAttempt( - provider, returnUrl, requiresMemberAccess, binding, now + Lifetime, null); - } - - return binding; - } - - public void Complete(string attemptId, HeimdallVerifiedIdentity identity) - { - lock (_gate) - { - if (!_attempts.TryGetValue(attemptId, out var attempt) || - attempt.ExpiresAt <= timeProvider.GetUtcNow() || - attempt.Identity is not null) - { - throw new HeimdallAuthException("Heimdall callback did not match a pending attempt."); - } - - _attempts[attemptId] = attempt with { Identity = identity }; - } - } - - public HeimdallRedeemedAttempt TryTake(string attemptId, string? binding) - { - lock (_gate) - { - if (!_attempts.TryGetValue(attemptId, out var attempt) || - attempt.ExpiresAt <= timeProvider.GetUtcNow() || - binding is null || - !System.Security.Cryptography.CryptographicOperations.FixedTimeEquals( - System.Text.Encoding.UTF8.GetBytes(binding), - System.Text.Encoding.UTF8.GetBytes(attempt.Binding))) - { - throw new HeimdallAuthException("Heimdall auth attempt is not valid for this browser."); - } - - if (attempt.Identity is null) - { - throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet."); - } - - _attempts.Remove(attemptId); - return new HeimdallRedeemedAttempt( - attempt.Provider, attempt.ReturnUrl, attempt.RequiresMemberAccess, attempt.Identity); - } - } -} - -public sealed record HeimdallAuthAttempt( +/// An attempt Start issued. It exists only inside its protected cookie. +public sealed record HeimdallAttempt( + string Nonce, string Provider, - string ReturnUrl, bool RequiresMemberAccess, - string Binding, - DateTimeOffset ExpiresAt, - HeimdallVerifiedIdentity? Identity); - -public sealed record HeimdallRedeemedAttempt( - string Provider, - string ReturnUrl, - bool RequiresMemberAccess, - HeimdallVerifiedIdentity Identity); + LocalReturnPath ReturnPath, + DateTimeOffset ExpiresAt); public sealed record HeimdallStartRequest( [property: JsonPropertyName("appSlug")] string AppSlug, @@ -364,11 +368,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, @@ -382,17 +382,32 @@ public sealed record HeimdallEntitlementPolicy( public sealed record HeimdallStartResponse( [property: JsonPropertyName("authorizationUrl")] string AuthorizationUrl); -public sealed record HeimdallBackendCallbackPayload( - [property: JsonPropertyName("attemptId")] string AttemptId, +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); public sealed record HeimdallAccount( [property: JsonPropertyName("id")] string Id); -public static class HeimdallBindingCookie +/// +/// The per-attempt cookie: named for the attempt's nonce so two attempts in one browser both complete. +/// A name is only built from a well-formed nonce, so request input never becomes an arbitrary cookie name. +/// +public static class HeimdallAttemptCookie { - public const string Name = "bifrost_heimdall_binding"; + public const string Prefix = "bifrost_heimdall_"; + public const int NonceLength = 43; + + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10); + + public static string? NameFor(string? nonce) => + nonce is { Length: NonceLength } && nonce.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_') + ? Prefix + nonce + : null; public static CookieOptions Options() => new() { @@ -400,6 +415,6 @@ public static class HeimdallBindingCookie Secure = true, SameSite = SameSiteMode.Lax, Path = "/auth/heimdall", - MaxAge = HeimdallAuthAttemptStore.Lifetime + MaxAge = Lifetime }; } diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs new file mode 100644 index 0000000..662fb16 --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs @@ -0,0 +1,46 @@ +namespace Bifrost.Web.Features.Heimdall; + +/// +/// The page Heimdall's "Return to the app" lands on. The completion code arrives in the URL fragment, which +/// the server never sees, so the page's script reads it, drops it from the address bar, and posts it with +/// the attempt to the same path. The page is static: it never contains a code. +/// +public static class HeimdallCompletePage +{ + public const string Html = """ + + + + + + + Signing in - Bifrost + + +

Finishing sign-in...

+ +
+ + +
+ + + + """; +} diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs index 58d67bf..38f71f1 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -31,8 +31,7 @@ public sealed class HeimdallTokenVerifier( private static readonly string[] MemberCapabilities = ["member_access", "app_access"]; private readonly SemaphoreSlim _fetchGate = new(1, 1); - private Dictionary _keys = new(StringComparer.Ordinal); - private DateTimeOffset _fetchedAt = DateTimeOffset.MinValue; + private volatile KeySnapshot _keys = new(new Dictionary(StringComparer.Ordinal), DateTimeOffset.MinValue); private DateTimeOffset _attemptedAt = DateTimeOffset.MinValue; public async Task VerifyAsync( @@ -69,6 +68,8 @@ public sealed class HeimdallTokenVerifier( return CheckClaims(payload.RootElement, heimdallOptions.Value, payloadAccountId); } + private sealed record KeySnapshot(Dictionary Keys, DateTimeOffset FetchedAt); + private static byte[] Decode(string segment) { try @@ -175,21 +176,36 @@ public sealed class HeimdallTokenVerifier( capabilities.Any(capability => MemberCapabilities.Contains(capability, StringComparer.OrdinalIgnoreCase))); } + /// The JWKS client waits a short, bounded time: a slow fetch must not hold sign-ins for long. + public static void ConfigureClient(HttpClient client, HeimdallOptions options) + { + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); + client.Timeout = TimeSpan.FromSeconds(10); + } + private async Task FindKeyAsync(string kid, CancellationToken cancellationToken) { + if (TryFreshKey(kid, out var cached)) + { + return cached; + } + await _fetchGate.WaitAsync(cancellationToken); try { - var now = timeProvider.GetUtcNow(); - var stale = now - _fetchedAt >= KeyMaxAge; - if ((stale || !_keys.ContainsKey(kid)) && now - _attemptedAt >= KeyRefreshInterval) + if (TryFreshKey(kid, out cached)) { - _attemptedAt = now; - _keys = await FetchKeysAsync(cancellationToken); - _fetchedAt = now; + return cached; } - return _keys.GetValueOrDefault(kid); + var now = timeProvider.GetUtcNow(); + if (now - _attemptedAt >= KeyRefreshInterval) + { + _attemptedAt = now; + _keys = new KeySnapshot(await FetchKeysAsync(cancellationToken), now); + } + + return _keys.Keys.GetValueOrDefault(kid); } finally { @@ -197,13 +213,20 @@ public sealed class HeimdallTokenVerifier( } } + 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> FetchKeysAsync(CancellationToken cancellationToken) { try { return await ReadKeysAsync(cancellationToken); } - catch (Exception error) when (error is not OperationCanceledException) + catch (Exception) when (!cancellationToken.IsCancellationRequested) { throw Refuse("Heimdall keys could not be fetched"); } diff --git a/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs new file mode 100644 index 0000000..e9c97d1 --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs @@ -0,0 +1,30 @@ +namespace Bifrost.Web.Features.Shared; + +/// +/// A sign-in return target that stays on this site. The only way to make one is , which +/// accepts a path that starts with a single slash and holds no control character, and otherwise yields +/// . Callers parse once at entry and carry the typed value from there. +/// +public sealed record LocalReturnPath +{ + private LocalReturnPath(string value) => Value = value; + + public static LocalReturnPath Default { get; } = new("/App"); + + public string Value { get; } + + public static LocalReturnPath Parse(string? candidate) + { + if (string.IsNullOrEmpty(candidate) || + candidate[0] != '/' || + candidate.Any(char.IsControl) || + (candidate.Length > 1 && (candidate[1] == '/' || candidate[1] == '\\'))) + { + return Default; + } + + return new LocalReturnPath(candidate); + } + + public override string ToString() => Value; +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 49e70b6..0123ce5 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -124,18 +124,11 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); -builder.Services.AddSingleton(); builder.Services.AddHttpClient((serviceProvider, client) => -{ - var options = serviceProvider.GetRequiredService>().Value; - client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); -}); + HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); builder.Services.AddSingleton(); builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => -{ - var options = serviceProvider.GetRequiredService>().Value; - client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); -}); + HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.stripe.com/"); @@ -305,22 +298,18 @@ app.MapPost("/github/webhooks", async ( app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions 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; } @@ -332,7 +321,7 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions hostOptions, + ILogger 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", @@ -390,34 +375,20 @@ app.MapGet("/auth/heimdall/{provider}", async ( var start = await heimdallAuthService.StartAsync( provider, hostOptions.Value.PublicBaseUrl, - returnUrl, + returnPath, requireMemberAccess, cancellationToken); - httpContext.Response.Cookies.Append(HeimdallBindingCookie.Name, start.Binding, HeimdallBindingCookie.Options()); + httpContext.Response.Cookies.Append( + HeimdallAttemptCookie.NameFor(start.Nonce), + 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", async ( - HeimdallBackendCallbackPayload payload, - HeimdallAuthService heimdallAuthService, - ILogger logger, - CancellationToken cancellationToken) => -{ - try - { - await heimdallAuthService.StoreBackendCallbackAsync(payload, cancellationToken); - return Results.Accepted(); - } - catch (HeimdallAuthException error) - { - logger.LogWarning("Heimdall callback refused: {Reason}", error.Message); - return Results.BadRequest("Heimdall callback refused."); + logger.LogWarning("Heimdall start refused: {Reason}", error.Message); + return Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); @@ -543,37 +514,46 @@ 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 hostOptions, + ILogger 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 cookieName = HeimdallAttemptCookie.NameFor(attempt); try { - var signIn = await heimdallAuthService.CompleteAttemptAsync( - attemptId, - httpContext.Request.Cookies[HeimdallBindingCookie.Name], + var signIn = await heimdallAuthService.CompleteAsync( + hostOptions.Value.PublicBaseUrl, + attempt, + cookieName is null ? null : httpContext.Request.Cookies[cookieName], + form?["code"].ToString(), cancellationToken); await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal); - httpContext.Response.Cookies.Delete(HeimdallBindingCookie.Name, HeimdallBindingCookie.Options()); - 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(cookieName!, HeimdallAttemptCookie.Options()); + 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 (cookieName is not null) + { + httpContext.Response.Cookies.Delete(cookieName, HeimdallAttemptCookie.Options()); + } + + return Results.Redirect(HeimdallAuthService.ErrorRedirect); } -}).AllowAnonymous(); +}).AllowAnonymous().DisableAntiforgery(); app.MapPost("/auth/sign-out", async (HttpContext httpContext) => { @@ -591,6 +571,23 @@ static IResult JsonResult(T value, JsonSerializerOptions options, int statusC "application/json", statusCode: statusCode); +static async Task 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"; diff --git a/src/Bifrost.Web/appsettings.json b/src/Bifrost.Web/appsettings.json index b24dd83..47bbb7b 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -27,6 +27,7 @@ "Heimdall": { "BaseUrl": "https://heimdall.gamecult.org", "AppSlug": "bifrost", + "AppSecret": "", "DiscordGuildId": "", "DiscordAllowedRoleIds": [], "PatreonTierTitle": "Inner Sanctum", diff --git a/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs b/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs deleted file mode 100644 index 8837c2a..0000000 --- a/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs +++ /dev/null @@ -1,893 +0,0 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Bifrost.Web.Domain; -using Bifrost.Web.Features.Heimdall; -using Bifrost.Web.Tests.Support; -using Microsoft.AspNetCore.WebUtilities; - -namespace Bifrost.Web.Tests; - -/// -/// Sign-in through the Heimdall callback, against a local Bifrost with a test JWKS. Deliveries are shaped -/// like Heimdall's backend-handoff.ts payload and claims.ts claim set; forgeries differ from them in the -/// one property under test. -/// -public sealed class HeimdallCallbackTests -{ - private static string Flip(string value) => value[..^1] + (value[^1] == '0' ? '1' : '0'); - - private static async Task DeliverGenuine( - HeimdallHost host, StartedAttempt attempt, string accountId, string[]? capabilities = null, string? displayName = "Genuine Name") - { - return await host.DeliverAsync(HeimdallShapes.Delivery( - attempt.AttemptId, accountId, host.Token(accountId, capabilities, displayName))); - } - - private static void AssertLogged(HeimdallHost host, string reason) => - Assert.Contains("Heimdall callback refused: " + reason, string.Join("\n", host.Logs.Lines)); - - private static void AssertTokenRefused(HeimdallHost host, string rule) => - AssertLogged(host, $"Heimdall token refused: {rule}."); - - private static string ErrorLocation(string message) => "/?auth=heimdall-error&detail=" + Uri.EscapeDataString(message); - - private static async Task AssertRefused(HeimdallHost host, StartedAttempt attempt, string accountId, HttpResponseMessage delivery) - { - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - Assert.False(wait.SignedIn); - Assert.Equal(HttpStatusCode.OK, wait.Response.StatusCode); - Assert.Null(await host.Account(accountId)); - } - - [Fact] - public async Task Genuine_delivery_signs_in_the_account_the_token_names() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(returnUrl: "/App/Mill"); - - using var delivery = await DeliverGenuine(host, attempt, accountId); - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - - var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - Assert.True(wait.SignedIn); - Assert.Equal(HttpStatusCode.Redirect, wait.Response.StatusCode); - Assert.Equal("/App/Mill", wait.Response.Headers.Location!.OriginalString); - - var account = await host.Account(accountId); - Assert.NotNull(account); - Assert.Equal("Genuine Name", account!.DisplayName); - Assert.Equal(MembershipStatus.Authenticated, account.Membership!.Status); - Assert.Empty(account.Membership.RoleAssignments); - - var cleared = HeimdallHost.SetCookieHeader(wait.Response, HeimdallHost.BindingCookie); - Assert.NotNull(cleared); - Assert.Contains("1970", cleared); - } - - [Fact] - public async Task The_display_name_is_the_claim_and_falls_back_to_the_account_id() - { - using var host = new HeimdallHost(); - var named = HeimdallShapes.NewAccountId(); - var unnamed = HeimdallShapes.NewAccountId(); - - var first = await host.StartAsync(); - await DeliverGenuine(host, first, named, displayName: "Claimed Name"); - Assert.True((await host.WaitAsync(first.AttemptId, first.Binding)).SignedIn); - - var second = await host.StartAsync(); - await DeliverGenuine(host, second, unnamed, displayName: null); - Assert.True((await host.WaitAsync(second.AttemptId, second.Binding)).SignedIn); - - Assert.Equal("Claimed Name", (await host.Account(named))!.DisplayName); - Assert.Equal(unnamed, (await host.Account(unnamed))!.DisplayName); - } - - [Fact] - public async Task A_callback_without_an_access_token_signs_nobody_in() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var payload = HeimdallShapes.Delivery(attempt.AttemptId, accountId, "unused"); - payload.Remove("accessToken"); - - using var delivery = await host.DeliverAsync(payload); - - await AssertRefused(host, attempt, accountId, delivery); - AssertLogged(host, "Heimdall callback carried no access token."); - } - - [Fact] - public async Task A_callback_without_an_attempt_id_signs_nobody_in() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(" ", accountId, host.Token(accountId))); - - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - AssertLogged(host, "Heimdall callback omitted attempt id."); - } - - [Fact] - public async Task An_empty_access_token_signs_nobody_in() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, " ")); - - await AssertRefused(host, attempt, accountId, delivery); - AssertLogged(host, "Heimdall callback carried no access token."); - } - - [Theory] - [InlineData("other-key-same-kid", "token signature is invalid")] - [InlineData("other-key-other-kid", "token key is not published")] - [InlineData("tampered-payload", "token signature is invalid")] - [InlineData("alg-none", "token algorithm is not EdDSA")] - [InlineData("alg-hs256", "token algorithm is not EdDSA")] - [InlineData("header-array", "token algorithm is not EdDSA")] - [InlineData("header-not-json", "token header is not JSON")] - [InlineData("truncated-signature", "token signature is invalid")] - [InlineData("no-kid", "token has no key id")] - [InlineData("unpublished-kid", "token key is not published")] - [InlineData("four-segments", "token is not a compact JWS")] - [InlineData("two-segments", "token is not a compact JWS")] - [InlineData("not-base64url", "token is not base64url")] - [InlineData("payload-not-json", "token payload is not JSON")] - [InlineData("payload-not-object", "token payload is not an object")] - public async Task A_forged_token_signs_nobody_in(string forgery, string rule) - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var claims = HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access"]); - var claimBytes = JsonSerializer.SerializeToUtf8Bytes(claims); - var genuine = host.Key.Sign(claims); - var parts = genuine.Split('.'); - - var token = forgery switch - { - "other-key-same-kid" => HeimdallKey.Generate(host.Key.Kid).Sign(claims), - "other-key-other-kid" => HeimdallKey.Generate().Sign(claims), - "tampered-payload" => parts[0] + "." + WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes( - HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access", "x"]))) + "." + parts[2], - "alg-none" => WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"none\",\"typ\":\"JWT\"}")) + "." + parts[1] + ".", - "alg-hs256" => host.Key.Sign(claims, alg: "HS256"), - "header-array" => host.Key.SignParts(Encoding.UTF8.GetBytes("[1]"), claimBytes), - "header-not-json" => host.Key.SignParts(Encoding.UTF8.GetBytes("not json"), claimBytes), - "truncated-signature" => parts[0] + "." + parts[1] + "." + - WebEncoders.Base64UrlEncode(WebEncoders.Base64UrlDecode(parts[2])[..63]), - "no-kid" => host.Key.Sign(claims, kid: null), - "unpublished-kid" => host.Key.Sign(claims, kid: "ed25519-" + new string('0', 12)), - "four-segments" => genuine + ".AAAA", - "two-segments" => parts[0] + "." + parts[1], - "not-base64url" => parts[0] + "." + parts[1] + ".!!" + parts[2][2..], - "payload-not-json" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("not json")), - "payload-not-object" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("[1]")), - _ => throw new InvalidOperationException(forgery) - }; - Assert.NotEqual(genuine, token); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - await AssertRefused(host, attempt, accountId, delivery); - AssertTokenRefused(host, rule); - } - - [Fact] - public async Task A_forged_token_cannot_sign_in_as_an_existing_account() - { - using var host = new HeimdallHost(); - var victim = HeimdallShapes.NewAccountId(); - var real = await host.StartAsync(); - await DeliverGenuine(host, real, victim, displayName: "Victim Name"); - Assert.True((await host.WaitAsync(real.AttemptId, real.Binding)).SignedIn); - - var attempt = await host.StartAsync(); - var forged = HeimdallKey.Generate(host.Key.Kid).Sign(HeimdallShapes.AccessClaims(victim, host.Clock.Now, displayName: "Mallory")); - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, victim, forged)); - - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - Assert.False((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - Assert.Equal("Victim Name", (await host.Account(victim))!.DisplayName); - } - - [Theory] - [InlineData("aud-other-app", "token audience is not this app")] - [InlineData("aud-case", "token audience is not this app")] - [InlineData("aud-array", "token audience is not this app")] - [InlineData("aud-missing", "token audience is not this app")] - [InlineData("app-slug-other", "token app is not this app")] - [InlineData("app-missing", "token app is not this app")] - [InlineData("app-not-object", "token app is not this app")] - [InlineData("typ-refresh", "token type is not an access token")] - [InlineData("typ-missing", "token type is not an access token")] - [InlineData("iss-other", "token issuer is not Heimdall")] - [InlineData("iss-trailing-slash", "token issuer is not Heimdall")] - [InlineData("expired", "token has expired")] - [InlineData("exp-missing", "token has expired")] - [InlineData("exp-string", "token has expired")] - [InlineData("exp-out-of-range", "token has expired")] - [InlineData("exp-negative", "token has expired")] - [InlineData("not-yet-valid", "token is not yet valid")] - [InlineData("nbf-missing", "token is not yet valid")] - [InlineData("nbf-negative", "token is not yet valid")] - [InlineData("sub-differs", "token subject does not match the account")] - [InlineData("account-id-differs", "token subject does not match the account")] - [InlineData("account-id-missing", "token subject does not match the account")] - [InlineData("sub-empty", "token subject does not match the account")] - public async Task A_signed_token_with_a_wrong_claim_signs_nobody_in(string wrong, string rule) - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var now = host.Clock.Now.ToUnixTimeSeconds(); - var token = host.Token(accountId, ["member_access"], mutate: claims => - { - switch (wrong) - { - case "aud-other-app": claims["aud"] = "ghostlight"; break; - case "aud-case": claims["aud"] = "Bifrost"; break; - case "aud-array": claims["aud"] = new[] { "bifrost" }; break; - case "aud-missing": claims.Remove("aud"); break; - case "app-slug-other": claims["app"] = new Dictionary { ["slug"] = "ghostlight" }; break; - case "app-missing": claims.Remove("app"); break; - case "app-not-object": claims["app"] = "bifrost"; break; - case "typ-refresh": claims["typ"] = "heimdall_refresh"; break; - case "typ-missing": claims.Remove("typ"); break; - case "iss-other": claims["iss"] = "https://heimdall.example"; break; - case "iss-trailing-slash": claims["iss"] = HeimdallShapes.Issuer + "/"; break; - case "expired": claims["exp"] = now - 3600; break; - case "exp-missing": claims.Remove("exp"); break; - case "exp-string": claims["exp"] = (now + 900).ToString(); break; - case "exp-out-of-range": claims["exp"] = long.MaxValue; break; - case "exp-negative": claims["exp"] = -1; break; - case "not-yet-valid": claims["nbf"] = now + 3600; claims["exp"] = now + 7200; break; - case "nbf-missing": claims.Remove("nbf"); break; - case "nbf-negative": claims["nbf"] = -1; break; - case "sub-differs": claims["sub"] = Flip(accountId); break; - case "account-id-differs": claims["account_id"] = Flip(accountId); break; - case "account-id-missing": claims.Remove("account_id"); break; - case "sub-empty": claims["sub"] = string.Empty; claims["account_id"] = string.Empty; break; - default: throw new InvalidOperationException(wrong); - } - }); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - await AssertRefused(host, attempt, accountId, delivery); - AssertTokenRefused(host, rule); - } - - [Fact] - public async Task A_token_for_a_different_account_than_the_payload_names_is_refused_by_one_character() - { - using var host = new HeimdallHost(); - var tokenAccount = HeimdallShapes.NewAccountId(); - var payloadAccount = Flip(tokenAccount); - var attempt = await host.StartAsync(); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery( - attempt.AttemptId, payloadAccount, host.Token(tokenAccount))); - - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - Assert.False((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - Assert.Null(await host.Account(tokenAccount)); - Assert.Null(await host.Account(payloadAccount)); - AssertTokenRefused(host, "token subject does not match the account"); - } - - [Fact] - public async Task A_payload_with_no_account_is_refused() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var payload = HeimdallShapes.Delivery(attempt.AttemptId, accountId, host.Token(accountId)); - payload["account"] = null; - - using var delivery = await host.DeliverAsync(payload); - - await AssertRefused(host, attempt, accountId, delivery); - AssertTokenRefused(host, "token subject does not match the account"); - } - - [Theory] - [InlineData(60, 900, true)] - [InlineData(61, 900, false)] - [InlineData(-1000, -59, true)] - [InlineData(-1000, -60, false)] - public async Task Token_validity_allows_a_minute_of_clock_skew(int notBeforeOffset, int expiresOffset, bool accepted) - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var now = host.Clock.Now.ToUnixTimeSeconds(); - var token = host.Token(accountId, mutate: claims => - { - claims["nbf"] = now + notBeforeOffset; - claims["exp"] = now + expiresOffset; - }); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - Assert.Equal(accepted ? HttpStatusCode.Accepted : HttpStatusCode.BadRequest, delivery.StatusCode); - } - - [Fact] - public async Task A_well_signed_token_for_an_attempt_start_never_issued_creates_nothing() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var unissued = Guid.NewGuid().ToString("N"); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(unissued, accountId, host.Token(accountId))); - - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - var wait = await host.WaitAsync(unissued, "anything"); - Assert.False(wait.SignedIn); - Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), wait.Response.Headers.Location!.OriginalString); - Assert.Null(await host.Account(accountId)); - AssertLogged(host, "Heimdall callback did not match a pending attempt."); - } - - [Fact] - public async Task A_second_delivery_does_not_replace_the_first() - { - using var host = new HeimdallHost(); - var first = HeimdallShapes.NewAccountId(); - var second = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - - using var accepted = await DeliverGenuine(host, attempt, first); - using var replaced = await DeliverGenuine(host, attempt, second); - - Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, replaced.StatusCode); - AssertLogged(host, "Heimdall callback did not match a pending attempt."); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - Assert.NotNull(await host.Account(first)); - Assert.Null(await host.Account(second)); - } - - [Fact] - public async Task An_attempt_is_deliverable_until_ten_minutes_after_start() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - host.Clock.Advance(HeimdallAuthAttemptStore.Lifetime - TimeSpan.FromSeconds(1)); - - using var delivery = await DeliverGenuine(host, attempt, accountId); - - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - } - - [Fact] - public async Task An_expired_attempt_is_refused_at_callback() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - host.Clock.Advance(HeimdallAuthAttemptStore.Lifetime); - - using var delivery = await DeliverGenuine(host, attempt, accountId); - - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - AssertLogged(host, "Heimdall callback did not match a pending attempt."); - var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - Assert.False(wait.SignedIn); - Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), wait.Response.Headers.Location!.OriginalString); - } - - [Fact] - public async Task A_delivered_attempt_expires_at_wait() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - await DeliverGenuine(host, attempt, accountId); - host.Clock.Advance(HeimdallAuthAttemptStore.Lifetime); - - var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - - Assert.False(wait.SignedIn); - Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), wait.Response.Headers.Location!.OriginalString); - Assert.Null(await host.Account(accountId)); - } - - [Fact] - public void A_full_store_drops_the_attempt_that_expires_first() - { - var clock = new TestClock(); - var store = new HeimdallAuthAttemptStore(clock); - var identity = new HeimdallVerifiedIdentity("account", "Name", false); - for (var i = 0; i < HeimdallAuthAttemptStore.Capacity; i++) - { - store.Create($"attempt-{i}", "discord", "/App", false); - clock.Advance(TimeSpan.FromMilliseconds(1)); - } - - // Exactly at capacity nothing has been dropped yet. - store.Complete("attempt-1", identity); - store.Create("attempt-extra", "discord", "/App", false); - - Assert.Throws(() => store.Complete("attempt-0", identity)); - store.Complete("attempt-2", identity); - store.Complete("attempt-extra", identity); - store.Complete($"attempt-{HeimdallAuthAttemptStore.Capacity - 1}", identity); - } - - [Fact] - public async Task Start_sets_a_private_per_attempt_binding_cookie() - { - using var host = new HeimdallHost(); - var first = await host.StartAsync(); - var second = await host.StartAsync(); - - Assert.Equal(HttpStatusCode.Redirect, first.Response.StatusCode); - var header = HeimdallHost.SetCookieHeader(first.Response, HeimdallHost.BindingCookie); - Assert.NotNull(header); - var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray(); - Assert.Contains("httponly", attributes); - Assert.Contains("secure", attributes); - Assert.Contains("samesite=lax", attributes); - Assert.Contains("path=/auth/heimdall", attributes); - Assert.Contains("max-age=600", attributes); - - Assert.Equal(43, first.Binding!.Length); - Assert.NotEqual(first.Binding, second.Binding); - Assert.DoesNotContain(first.AttemptId, header); - Assert.NotEqual(first.AttemptId, first.Binding); - } - - [Fact] - public async Task Wait_signs_in_only_the_browser_that_holds_the_binding_and_only_once() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var other = await host.StartAsync(); - await DeliverGenuine(host, attempt, accountId); - - var wrong = new Dictionary - { - ["no cookie"] = null, - ["empty cookie"] = string.Empty, - ["another attempt's cookie"] = other.Binding, - ["same-shape random cookie"] = WebEncoders.Base64UrlEncode(new byte[32]), - ["the attempt id as cookie"] = attempt.AttemptId, - ["a prefix of the cookie"] = attempt.Binding![..^1], - ["the cookie with a suffix"] = attempt.Binding + "A" - }; - foreach (var (name, binding) in wrong) - { - var refused = await host.WaitAsync(attempt.AttemptId, binding); - Assert.False(refused.SignedIn, name); - Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), refused.Response.Headers.Location!.OriginalString); - } - - Assert.Null(await host.Account(accountId)); - - var granted = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - Assert.True(granted.SignedIn); - - var replay = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - Assert.False(replay.SignedIn); - } - - [Fact] - public async Task Wait_before_delivery_is_pending_and_does_not_consume_the_attempt() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - - var early = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - Assert.False(early.SignedIn); - Assert.Equal(HttpStatusCode.OK, early.Response.StatusCode); - Assert.Equal("2", Assert.Single(early.Response.Headers.GetValues("Refresh"))); - - await DeliverGenuine(host, attempt, accountId); - - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - } - - [Fact] - public async Task Wait_without_an_attempt_id_signs_nobody_in() - { - using var host = new HeimdallHost(); - using var client = host.NewClient(); - - using var response = await client.GetAsync("/auth/heimdall/wait"); - - Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); - Assert.Null(HeimdallHost.CookieValue(response, ".AspNetCore.Cookies")); - } - - [Theory] - [InlineData("member_access", true)] - [InlineData("app_access", true)] - [InlineData("MEMBER_ACCESS", true)] - [InlineData("billing_access", false)] - public async Task Member_access_comes_from_the_token_capabilities_alone(string capability, bool grants) - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(member: true); - var payload = HeimdallShapes.Delivery( - attempt.AttemptId, - accountId, - host.Token(accountId, [capability]), - looseCapabilities: grants ? [] : ["member_access", "app_access"], - looseFacts: grants ? [] : ["entitlement.app_access"]); - - using var delivery = await host.DeliverAsync(payload); - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - - var account = await host.Account(accountId); - if (grants) - { - Assert.True(wait.SignedIn); - Assert.Equal(MembershipStatus.Active, account!.Membership!.Status); - Assert.Contains(account.Membership.RoleAssignments, x => x.Role == MemberRole.StandardMember); - } - else - { - Assert.False(wait.SignedIn); - Assert.Equal(ErrorLocation("Heimdall did not grant Bifrost member access."), wait.Response.Headers.Location!.OriginalString); - Assert.Null(account); - } - } - - [Fact] - public async Task A_token_capability_grants_no_role_to_a_sign_in_that_did_not_ask_for_member_access() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(member: false); - - await DeliverGenuine(host, attempt, accountId, ["member_access"]); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - - var account = await host.Account(accountId); - Assert.Equal(MembershipStatus.Authenticated, account!.Membership!.Status); - Assert.Empty(account.Membership.RoleAssignments); - } - - [Fact] - public async Task Loose_capabilities_cannot_turn_a_plain_token_into_member_access() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(member: false); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery( - attempt.AttemptId, - accountId, - host.Token(accountId), - looseCapabilities: ["member_access"], - looseFacts: ["entitlement.app_access"])); - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - - var account = await host.Account(accountId); - Assert.Equal(MembershipStatus.Authenticated, account!.Membership!.Status); - Assert.Empty(account.Membership.RoleAssignments); - } - - [Fact] - public async Task The_loose_return_to_and_name_decide_nothing() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(returnUrl: "/App/Mill"); - - await host.DeliverAsync(HeimdallShapes.Delivery( - attempt.AttemptId, accountId, host.Token(accountId), looseName: "Loose Name", returnTo: "https://evil.example/landing")); - var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - - Assert.Equal("/App/Mill", wait.Response.Headers.Location!.OriginalString); - Assert.Equal("Genuine Name", (await host.Account(accountId))!.DisplayName); - } - - [Fact] - public async Task Keys_are_fetched_once_and_cached() - { - using var host = new HeimdallHost(); - for (var i = 0; i < 3; i++) - { - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - using var delivery = await DeliverGenuine(host, attempt, accountId); - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - } - - Assert.Equal(1, host.Heimdall.JwksFetches); - } - - [Fact] - public async Task An_unknown_key_id_refetches_at_most_every_thirty_seconds() - { - using var host = new HeimdallHost(); - var unknown = HeimdallKey.Generate(); - async Task DeliverUnknown() - { - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery( - attempt.AttemptId, accountId, host.Token(accountId, signer: unknown))); - return delivery.StatusCode; - } - - Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); - Assert.Equal(1, host.Heimdall.JwksFetches); - - host.Clock.Advance(TimeSpan.FromSeconds(29)); - Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); - Assert.Equal(1, host.Heimdall.JwksFetches); - - host.Clock.Advance(TimeSpan.FromSeconds(1)); - Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); - Assert.Equal(2, host.Heimdall.JwksFetches); - Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); - Assert.Equal(2, host.Heimdall.JwksFetches); - } - - [Fact] - public async Task A_rotated_in_key_is_accepted_once_the_refetch_interval_has_passed() - { - using var host = new HeimdallHost(); - var rotated = HeimdallKey.Generate(); - var accountId = HeimdallShapes.NewAccountId(); - - var early = await host.StartAsync(); - using var refused = await host.DeliverAsync(HeimdallShapes.Delivery( - early.AttemptId, accountId, host.Token(accountId, signer: rotated))); - Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode); - - host.Heimdall.Published.Add(rotated); - host.Clock.Advance(TimeSpan.FromSeconds(30)); - var late = await host.StartAsync(); - using var accepted = await host.DeliverAsync(HeimdallShapes.Delivery( - late.AttemptId, accountId, host.Token(accountId, signer: rotated))); - - Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); - } - - [Fact] - public async Task Cached_keys_are_refetched_after_an_hour_and_a_withdrawn_key_stops_working() - { - using var host = new HeimdallHost(); - var warm = await host.StartAsync(); - var warmAccount = HeimdallShapes.NewAccountId(); - using (await DeliverGenuine(host, warm, warmAccount)) - { - } - - host.Heimdall.Published.Clear(); - host.Clock.Advance(TimeSpan.FromHours(1) - TimeSpan.FromSeconds(1)); - var inside = await host.StartAsync(); - using (var stillCached = await DeliverGenuine(host, inside, HeimdallShapes.NewAccountId())) - { - Assert.Equal(HttpStatusCode.Accepted, stillCached.StatusCode); - } - - Assert.Equal(1, host.Heimdall.JwksFetches); - - host.Clock.Advance(TimeSpan.FromSeconds(1)); - var outside = await host.StartAsync(); - using var withdrawn = await DeliverGenuine(host, outside, HeimdallShapes.NewAccountId()); - - Assert.Equal(HttpStatusCode.BadRequest, withdrawn.StatusCode); - Assert.Equal(2, host.Heimdall.JwksFetches); - } - - [Fact] - public async Task A_failing_key_fetch_refuses_without_hammering_and_recovers() - { - using var host = new HeimdallHost(); - host.Heimdall.JwksStatus = HttpStatusCode.InternalServerError; - var accountId = HeimdallShapes.NewAccountId(); - - var first = await host.StartAsync(); - using (var down = await DeliverGenuine(host, first, accountId)) - { - Assert.Equal(HttpStatusCode.BadRequest, down.StatusCode); - AssertTokenRefused(host, "Heimdall keys could not be fetched"); - } - - var second = await host.StartAsync(); - using (var stillDown = await DeliverGenuine(host, second, accountId)) - { - Assert.Equal(HttpStatusCode.BadRequest, stillDown.StatusCode); - } - - Assert.Equal(1, host.Heimdall.JwksFetches); - - host.Heimdall.JwksStatus = HttpStatusCode.OK; - host.Clock.Advance(TimeSpan.FromSeconds(30)); - var third = await host.StartAsync(); - using var recovered = await DeliverGenuine(host, third, accountId); - - Assert.Equal(HttpStatusCode.Accepted, recovered.StatusCode); - } - - [Theory] - [InlineData("rsa-kty")] - [InlineData("x25519-crv")] - [InlineData("short-x")] - public async Task A_published_key_that_is_not_a_full_ed25519_key_verifies_nothing(string junk) - { - using var host = new HeimdallHost(); - var kid = "ed25519-" + new string('a', 12); - var x = WebEncoders.Base64UrlEncode(host.Key.PublicKey); - host.Heimdall.ExtraJwks.Add(42); - host.Heimdall.ExtraJwks.Add(junk switch - { - "rsa-kty" => new Dictionary { ["kty"] = "RSA", ["crv"] = "Ed25519", ["kid"] = kid, ["x"] = x }, - "x25519-crv" => new Dictionary { ["kty"] = "OKP", ["crv"] = "X25519", ["kid"] = kid, ["x"] = x }, - _ => new Dictionary { ["kty"] = "OKP", ["crv"] = "Ed25519", ["kid"] = kid, ["x"] = x[..20] } - }); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var token = host.Key.Sign(HeimdallShapes.AccessClaims(accountId, host.Clock.Now), kid: kid); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - var genuine = await host.StartAsync(); - using var accepted = await DeliverGenuine(host, genuine, accountId); - Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); - } - - [Fact] - public async Task Capabilities_that_are_not_a_list_of_names_grant_nothing_and_do_not_crash() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var token = host.Token(accountId, mutate: claims => claims["capabilities"] = "member_access"); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - Assert.Empty((await host.Account(accountId))!.Membership!.RoleAssignments); - } - - [Fact] - public async Task Member_access_is_found_among_other_and_non_string_capabilities() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(member: true); - var token = host.Token(accountId, mutate: claims => - claims["capabilities"] = new object?[] { 7, null, "billing_access", "member_access" }); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - Assert.Contains((await host.Account(accountId))!.Membership!.RoleAssignments, x => x.Role == MemberRole.StandardMember); - } - - [Theory] - [InlineData(0L, 900L)] - [InlineData(-1L, 253402300799L)] - public async Task Numeric_dates_at_the_ends_of_the_valid_range_are_accepted(long notBefore, long expires) - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var now = host.Clock.Now.ToUnixTimeSeconds(); - var token = host.Token(accountId, mutate: claims => - { - claims["nbf"] = notBefore < 0 ? now : notBefore; - claims["exp"] = expires == 900 ? now + 900 : expires; - }); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - } - - [Theory] - [InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall rejected the auth start.")] - [InlineData(HttpStatusCode.OK, "{}", "Heimdall auth start returned no authorization URL.")] - [InlineData(HttpStatusCode.OK, "{\"authorizationUrl\":\" \"}", "Heimdall auth start returned no authorization URL.")] - public async Task A_start_Heimdall_cannot_serve_issues_no_attempt_and_no_cookie(HttpStatusCode status, string body, string message) - { - using var host = new HeimdallHost(); - host.Heimdall.StartStatus = status; - host.Heimdall.StartBody = body; - - var attempt = await host.StartAsync(); - - Assert.Equal(ErrorLocation(message), attempt.Response.Headers.Location!.OriginalString); - Assert.Null(attempt.Binding); - var wait = await host.WaitAsync(attempt.AttemptId, "anything"); - Assert.False(wait.SignedIn); - } - - [Fact] - public async Task Refusals_echo_no_input_in_responses_or_logs() - { - using var host = new HeimdallHost(); - var suffix = Guid.NewGuid().ToString("N"); - var attemptCanary = "CANARYATTEMPT" + suffix; - var accountCanary = "CANARYACCOUNT" + suffix; - var kidCanary = "CANARYKID" + suffix; - var claimCanary = "CANARYCLAIM" + suffix; - var providerCanary = "CANARYPROVIDER" + suffix; - var bindingCanary = "CANARYBINDING" + suffix; - var tokens = new List(); - - var canaryClaims = HeimdallShapes.AccessClaims(accountCanary, host.Clock.Now, mutate: claims => - { - claims["iss"] = claimCanary; - claims["aud"] = claimCanary; - claims["typ"] = claimCanary; - claims["display_name"] = claimCanary; - }); - var issued = await host.StartAsync(); - var wrongSubject = host.Token(HeimdallShapes.NewAccountId()); - tokens.Add(wrongSubject); - tokens.Add(HeimdallKey.Generate(kidCanary).Sign(canaryClaims, kid: kidCanary)); - tokens.Add(host.Key.Sign(canaryClaims)); - tokens.Add($"{claimCanary}.{claimCanary}.{claimCanary}"); - - var responses = new List(); - responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(attemptCanary, accountCanary, tokens[1]))); - responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(attemptCanary, accountCanary, tokens[2]))); - responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(attemptCanary, accountCanary, tokens[3]))); - responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(issued.AttemptId, accountCanary, wrongSubject))); - responses.Add((await host.WaitAsync(attemptCanary, bindingCanary)).Response); - responses.Add((await host.WaitAsync(issued.AttemptId, bindingCanary)).Response); - using (var client = host.NewClient()) - { - responses.Add(await client.GetAsync($"/auth/heimdall/{providerCanary}")); - responses.Add(await client.PostAsync( - "/auth/heimdall/callback", - new StringContent("{\"attemptId\":\"" + attemptCanary + "\",", Encoding.UTF8, "application/json"))); - } - - var forbidden = new List { attemptCanary, accountCanary, kidCanary, claimCanary, providerCanary, bindingCanary }; - forbidden.AddRange(tokens); - forbidden.AddRange(tokens.SelectMany(x => x.Split('.')).Where(x => x.Length > 8)); - - foreach (var response in responses) - { - var observed = new StringBuilder() - .Append(await response.Content.ReadAsStringAsync()) - .Append(response.ToString()); - foreach (var cookie in response.Headers.Where(x => x.Key == "Set-Cookie").SelectMany(x => x.Value)) - { - observed.Append(cookie); - } - - foreach (var canary in forbidden) - { - Assert.DoesNotContain(canary, observed.ToString()); - } - - response.Dispose(); - } - - var logs = string.Join("\n", host.Logs.Lines); - Assert.Contains("Heimdall callback refused", logs); - foreach (var canary in forbidden) - { - Assert.DoesNotContain(canary, logs); - } - } -} diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs new file mode 100644 index 0000000..853bd1f --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -0,0 +1,1109 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +/// +/// Sign-in through Heimdall's browser completion, against a local Bifrost with a test JWKS and a fake +/// Heimdall shaped like src/app.ts (start, redeem) and src/claims.ts. Forgeries differ from the genuine +/// shape in the one property under test. +/// +public sealed class HeimdallCompletionTests +{ + private const string ErrorRedirect = "/?auth=heimdall-error"; + + private static string Flip(string value) => value[..^1] + (value[^1] == '0' ? '1' : '0'); + + /// Changes one character in the middle of a protected value, which its authentication tag must notice. + private static string Corrupt(string value) => value[..20] + (value[20] == 'A' ? 'B' : 'A') + value[21..]; + + private static async Task Genuine( + HeimdallHost host, StartedAttempt attempt, string accountId, string[]? capabilities = null, string? displayName = "Genuine Name") => + await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, host.Token(accountId, capabilities, displayName))); + + private static string CodeWith( + HeimdallHost host, StartedAttempt attempt, string accountId, Action> edit, string? token = null) + { + var completion = HeimdallShapes.Completion(attempt.ReturnTo, accountId, token ?? host.Token(accountId)); + edit(completion); + return host.Heimdall.RegisterCode(completion); + } + + private static void AssertLogged(HeimdallHost host, string reason) => + Assert.Contains("Heimdall completion refused: " + reason, string.Join("\n", host.Logs.Lines)); + + private static void AssertTokenRefused(HeimdallHost host, string rule) => + AssertLogged(host, $"Heimdall token refused: {rule}."); + + private static void AssertRefusedRedirect(CompletionResult result) + { + Assert.Equal(HttpStatusCode.Redirect, result.Response.StatusCode); + Assert.Equal(ErrorRedirect, result.Response.Headers.Location!.OriginalString); + Assert.False(result.SignedIn); + } + + private static async Task AssertRefused(HeimdallHost host, string accountId, CompletionResult result) + { + AssertRefusedRedirect(result); + Assert.Null(await host.Account(accountId)); + } + + [Fact] + public async Task Genuine_completion_signs_in_once_in_the_starting_browser() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(returnUrl: "/App/Mill"); + var code = host.CodeFor(attempt, accountId); + + var done = await host.CompleteAsync(attempt, code); + + Assert.True(done.SignedIn); + Assert.Equal(HttpStatusCode.Redirect, done.Response.StatusCode); + Assert.Equal("/App/Mill", done.Response.Headers.Location!.OriginalString); + var account = await host.Account(accountId); + Assert.NotNull(account); + Assert.Equal("Genuine Name", account!.DisplayName); + Assert.Equal(MembershipStatus.Authenticated, account.Membership!.Status); + Assert.Empty(account.Membership.RoleAssignments); + var cleared = HeimdallHost.SetCookieHeader(done.Response, HeimdallAttemptCookie.Prefix + attempt.Nonce); + Assert.NotNull(cleared); + Assert.Contains("1970", cleared); + + var again = await host.CompleteAsync(attempt, code); + + AssertRefusedRedirect(again); + Assert.Equal(2, host.Heimdall.RedeemCalls); + AssertLogged(host, "Heimdall did not redeem the completion code."); + } + + [Fact] + public async Task The_display_name_is_the_claim_and_falls_back_to_the_account_id() + { + using var host = new HeimdallHost(); + var named = HeimdallShapes.NewAccountId(); + var unnamed = HeimdallShapes.NewAccountId(); + + Assert.True((await Genuine(host, await host.StartAsync(), named, displayName: "Claimed Name")).SignedIn); + Assert.True((await Genuine(host, await host.StartAsync(), unnamed, displayName: null)).SignedIn); + + Assert.Equal("Claimed Name", (await host.Account(named))!.DisplayName); + Assert.Equal(unnamed, (await host.Account(unnamed))!.DisplayName); + } + + [Theory] + [InlineData("absent")] + [InlineData("")] + [InlineData(" ")] + public async Task A_completion_without_an_access_token_signs_nobody_in(string token) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var code = CodeWith(host, attempt, accountId, completion => + { + if (token == "absent") + { + completion.Remove("accessToken"); + } + else + { + completion["accessToken"] = token; + } + }); + + var result = await host.CompleteAsync(attempt, code); + + await AssertRefused(host, accountId, result); + AssertLogged(host, "Heimdall completion carried no access token."); + } + + [Theory] + [InlineData(HttpStatusCode.Gone, "{}", "Heimdall did not redeem the completion code.")] + [InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall did not redeem the completion code.")] + [InlineData(HttpStatusCode.OK, "valid", "Heimdall did not redeem the completion code.")] + [InlineData(HttpStatusCode.Created, "not json", "Heimdall redeem could not be read.")] + [InlineData(HttpStatusCode.Created, "[1]", "Heimdall redeem could not be read.")] + [InlineData(HttpStatusCode.Created, "null", "Heimdall redeem returned no body.")] + public async Task A_redeem_that_is_not_a_201_with_a_readable_body_signs_nobody_in(HttpStatusCode status, string body, string reason) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + host.Heimdall.RedeemStatus = status; + host.Heimdall.RedeemBody = body == "valid" + ? JsonSerializer.Serialize(HeimdallShapes.Completion(attempt.ReturnTo, accountId, host.Token(accountId))) + : body; + + var result = await host.CompleteAsync(attempt, "a-code"); + + await AssertRefused(host, accountId, result); + AssertLogged(host, reason); + } + + [Theory] + [InlineData("other-key-same-kid", "token signature is invalid")] + [InlineData("other-key-other-kid", "token key is not published")] + [InlineData("tampered-payload", "token signature is invalid")] + [InlineData("alg-none", "token algorithm is not EdDSA")] + [InlineData("alg-hs256", "token algorithm is not EdDSA")] + [InlineData("header-array", "token algorithm is not EdDSA")] + [InlineData("header-not-json", "token header is not JSON")] + [InlineData("truncated-signature", "token signature is invalid")] + [InlineData("no-kid", "token has no key id")] + [InlineData("unpublished-kid", "token key is not published")] + [InlineData("four-segments", "token is not a compact JWS")] + [InlineData("two-segments", "token is not a compact JWS")] + [InlineData("not-base64url", "token is not base64url")] + [InlineData("payload-not-json", "token payload is not JSON")] + [InlineData("payload-not-object", "token payload is not an object")] + public async Task A_forged_token_signs_nobody_in(string forgery, string rule) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var claims = HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access"]); + var claimBytes = JsonSerializer.SerializeToUtf8Bytes(claims); + var genuine = host.Key.Sign(claims); + var parts = genuine.Split('.'); + + var token = forgery switch + { + "other-key-same-kid" => HeimdallKey.Generate(host.Key.Kid).Sign(claims), + "other-key-other-kid" => HeimdallKey.Generate().Sign(claims), + "tampered-payload" => parts[0] + "." + WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes( + HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access", "x"]))) + "." + parts[2], + "alg-none" => WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"none\",\"typ\":\"JWT\"}")) + "." + parts[1] + ".", + "alg-hs256" => host.Key.Sign(claims, alg: "HS256"), + "header-array" => host.Key.SignParts(Encoding.UTF8.GetBytes("[1]"), claimBytes), + "header-not-json" => host.Key.SignParts(Encoding.UTF8.GetBytes("not json"), claimBytes), + "truncated-signature" => parts[0] + "." + parts[1] + "." + + WebEncoders.Base64UrlEncode(WebEncoders.Base64UrlDecode(parts[2])[..63]), + "no-kid" => host.Key.Sign(claims, kid: null), + "unpublished-kid" => host.Key.Sign(claims, kid: "ed25519-" + new string('0', 12)), + "four-segments" => genuine + ".AAAA", + "two-segments" => parts[0] + "." + parts[1], + "not-base64url" => parts[0] + "." + parts[1] + ".!!" + parts[2][2..], + "payload-not-json" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("not json")), + "payload-not-object" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("[1]")), + _ => throw new InvalidOperationException(forgery) + }; + Assert.NotEqual(genuine, token); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, token)); + + await AssertRefused(host, accountId, result); + AssertTokenRefused(host, rule); + } + + [Fact] + public async Task A_forged_token_cannot_sign_in_as_an_existing_account() + { + using var host = new HeimdallHost(); + var victim = HeimdallShapes.NewAccountId(); + Assert.True((await Genuine(host, await host.StartAsync(), victim, displayName: "Victim Name")).SignedIn); + + var attempt = await host.StartAsync(); + var forged = HeimdallKey.Generate(host.Key.Kid).Sign(HeimdallShapes.AccessClaims(victim, host.Clock.Now, displayName: "Mallory")); + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, victim, forged)); + + AssertRefusedRedirect(result); + Assert.Equal("Victim Name", (await host.Account(victim))!.DisplayName); + } + + [Theory] + [InlineData("aud-other-app", "token audience is not this app")] + [InlineData("aud-case", "token audience is not this app")] + [InlineData("aud-array", "token audience is not this app")] + [InlineData("aud-missing", "token audience is not this app")] + [InlineData("app-slug-other", "token app is not this app")] + [InlineData("app-missing", "token app is not this app")] + [InlineData("app-not-object", "token app is not this app")] + [InlineData("typ-refresh", "token type is not an access token")] + [InlineData("typ-missing", "token type is not an access token")] + [InlineData("iss-other", "token issuer is not Heimdall")] + [InlineData("iss-trailing-slash", "token issuer is not Heimdall")] + [InlineData("expired", "token has expired")] + [InlineData("exp-missing", "token has expired")] + [InlineData("exp-string", "token has expired")] + [InlineData("exp-out-of-range", "token has expired")] + [InlineData("exp-negative", "token has expired")] + [InlineData("not-yet-valid", "token is not yet valid")] + [InlineData("nbf-missing", "token is not yet valid")] + [InlineData("nbf-negative", "token is not yet valid")] + [InlineData("sub-differs", "token subject does not match the account")] + [InlineData("account-id-differs", "token subject does not match the account")] + [InlineData("account-id-missing", "token subject does not match the account")] + [InlineData("sub-empty", "token subject does not match the account")] + public async Task A_signed_token_with_a_wrong_claim_signs_nobody_in(string wrong, string rule) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var now = host.Clock.Now.ToUnixTimeSeconds(); + var token = host.Token(accountId, ["member_access"], mutate: claims => + { + switch (wrong) + { + case "aud-other-app": claims["aud"] = "ghostlight"; break; + case "aud-case": claims["aud"] = "Bifrost"; break; + case "aud-array": claims["aud"] = new[] { "bifrost" }; break; + case "aud-missing": claims.Remove("aud"); break; + case "app-slug-other": claims["app"] = new Dictionary { ["slug"] = "ghostlight" }; break; + case "app-missing": claims.Remove("app"); break; + case "app-not-object": claims["app"] = "bifrost"; break; + case "typ-refresh": claims["typ"] = "heimdall_refresh"; break; + case "typ-missing": claims.Remove("typ"); break; + case "iss-other": claims["iss"] = "https://heimdall.example"; break; + case "iss-trailing-slash": claims["iss"] = HeimdallShapes.Issuer + "/"; break; + case "expired": claims["exp"] = now - 3600; break; + case "exp-missing": claims.Remove("exp"); break; + case "exp-string": claims["exp"] = (now + 900).ToString(); break; + case "exp-out-of-range": claims["exp"] = long.MaxValue; break; + case "exp-negative": claims["exp"] = -1; break; + case "not-yet-valid": claims["nbf"] = now + 3600; claims["exp"] = now + 7200; break; + case "nbf-missing": claims.Remove("nbf"); break; + case "nbf-negative": claims["nbf"] = -1; break; + case "sub-differs": claims["sub"] = Flip(accountId); break; + case "account-id-differs": claims["account_id"] = Flip(accountId); break; + case "account-id-missing": claims.Remove("account_id"); break; + case "sub-empty": claims["sub"] = string.Empty; claims["account_id"] = string.Empty; break; + default: throw new InvalidOperationException(wrong); + } + }); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, token)); + + await AssertRefused(host, accountId, result); + AssertTokenRefused(host, rule); + } + + [Fact] + public async Task A_token_for_a_different_account_than_the_redeemed_one_is_refused_by_one_character() + { + using var host = new HeimdallHost(); + var tokenAccount = HeimdallShapes.NewAccountId(); + var redeemedAccount = Flip(tokenAccount); + var attempt = await host.StartAsync(); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, redeemedAccount, host.Token(tokenAccount))); + + AssertRefusedRedirect(result); + Assert.Null(await host.Account(tokenAccount)); + Assert.Null(await host.Account(redeemedAccount)); + AssertTokenRefused(host, "token subject does not match the account"); + } + + [Fact] + public async Task A_redeem_response_with_no_account_is_refused() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var code = CodeWith(host, attempt, accountId, completion => completion["account"] = null); + + var result = await host.CompleteAsync(attempt, code); + + await AssertRefused(host, accountId, result); + AssertTokenRefused(host, "token subject does not match the account"); + } + + [Theory] + [InlineData(60, 900, true)] + [InlineData(61, 900, false)] + [InlineData(-1000, -59, true)] + [InlineData(-1000, -60, false)] + public async Task Token_validity_allows_a_minute_of_clock_skew(int notBeforeOffset, int expiresOffset, bool accepted) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var now = host.Clock.Now.ToUnixTimeSeconds(); + var token = host.Token(accountId, mutate: claims => + { + claims["nbf"] = now + notBeforeOffset; + claims["exp"] = now + expiresOffset; + }); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, token)); + + Assert.Equal(accepted, result.SignedIn); + } + + [Fact] + public async Task A_consent_link_completed_in_another_browser_signs_nobody_in() + { + using var host = new HeimdallHost(); + var victim = HeimdallShapes.NewAccountId(); + var attacker = await host.StartAsync(); + var victimsCode = host.CodeFor(attacker, victim); + + var swap = await host.CompleteAsync(attacker.Nonce, victimsCode, null); + + AssertRefusedRedirect(swap); + Assert.Null(await host.Account(victim)); + Assert.Equal(0, host.Heimdall.RedeemCalls); + AssertLogged(host, "Heimdall completion has no attempt for this browser."); + + var guess = await host.CompleteAsync(attacker.Nonce, "0123456789abcdef0123456789abcdef", attacker.Cookie); + + AssertRefusedRedirect(guess); + Assert.Equal(1, host.Heimdall.RedeemCalls); + Assert.Null(await host.Account(victim)); + } + + [Fact] + public async Task An_attackers_code_in_a_victims_attempt_signs_nobody_in() + { + using var host = new HeimdallHost(); + var attackerAccount = HeimdallShapes.NewAccountId(); + var victimAttempt = await host.StartAsync(); + var attackerAttempt = await host.StartAsync(); + var attackersCode = host.CodeFor(attackerAttempt, attackerAccount); + + var forced = await host.CompleteAsync(victimAttempt.Nonce, attackersCode, victimAttempt.Cookie); + + AssertRefusedRedirect(forced); + Assert.Null(await host.Account(attackerAccount)); + Assert.Equal(1, host.Heimdall.RedeemCalls); + AssertLogged(host, "Heimdall completion belongs to another attempt."); + + var crossed = await host.CompleteAsync(attackerAttempt.Nonce, attackersCode, victimAttempt.Cookie); + + AssertRefusedRedirect(crossed); + Assert.Equal(1, host.Heimdall.RedeemCalls); + } + + [Theory] + [InlineData("nonce-flipped")] + [InlineData("trailing-slash")] + [InlineData("other-host")] + [InlineData("other-scheme")] + [InlineData("other-path")] + [InlineData("extra-query")] + [InlineData("empty")] + public async Task A_redeemed_return_to_that_differs_by_one_character_is_refused(string difference) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var returnTo = difference switch + { + "nonce-flipped" => Flip(attempt.ReturnTo), + "trailing-slash" => attempt.ReturnTo + "/", + "other-host" => attempt.ReturnTo.Replace("https://localhost", "https://localhost.evil.example"), + "other-scheme" => attempt.ReturnTo.Replace("https://", "http://"), + "other-path" => attempt.ReturnTo.Replace("/complete", "/completed"), + "extra-query" => attempt.ReturnTo + "&x=1", + _ => string.Empty + }; + Assert.NotEqual(attempt.ReturnTo, returnTo); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, returnTo: returnTo)); + + await AssertRefused(host, accountId, result); + Assert.Equal(1, host.Heimdall.RedeemCalls); + AssertLogged(host, "Heimdall completion belongs to another attempt."); + } + + [Fact] + public async Task An_attempt_cookie_that_is_tampered_renamed_or_expired_is_no_attempt() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var other = await host.StartAsync(); + var onTime = await host.StartAsync(); + var atExpiry = await host.StartAsync(); + var afterExpiry = await host.StartAsync(); + var code = host.CodeFor(attempt, accountId); + var wrong = new Dictionary + { + ["empty cookie"] = string.Empty, + ["another attempt's cookie under this name"] = other.Cookie, + ["a corrupted byte"] = Corrupt(attempt.Cookie!), + ["a truncated value"] = attempt.Cookie![..^8], + ["a value with a suffix"] = attempt.Cookie + "A", + ["the nonce as the value"] = attempt.Nonce, + ["a same-shape random value"] = WebEncoders.Base64UrlEncode(new byte[attempt.Cookie.Length * 3 / 4]) + }; + + foreach (var (name, cookie) in wrong) + { + var refused = await host.CompleteAsync(attempt.Nonce, code, cookie); + AssertRefusedRedirect(refused); + Assert.True(refused.Response.Headers.Contains("Set-Cookie"), name); + } + + Assert.Equal(0, host.Heimdall.RedeemCalls); + Assert.Null(await host.Account(accountId)); + + host.Clock.Advance(HeimdallAttemptCookie.Lifetime - TimeSpan.FromSeconds(1)); + var accepted = await host.CompleteAsync(onTime, host.CodeFor(onTime, accountId)); + Assert.True(accepted.SignedIn); + Assert.Equal(1, host.Heimdall.RedeemCalls); + + host.Clock.Advance(TimeSpan.FromSeconds(1)); + var expiring = await host.CompleteAsync(atExpiry, host.CodeFor(atExpiry, accountId)); + AssertRefusedRedirect(expiring); + + host.Clock.Advance(TimeSpan.FromSeconds(1)); + var expired = await host.CompleteAsync(afterExpiry, host.CodeFor(afterExpiry, accountId)); + AssertRefusedRedirect(expired); + Assert.Equal(1, host.Heimdall.RedeemCalls); + } + + [Fact] + public async Task Anonymous_starts_cannot_displace_a_pending_sign_in() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var genuine = await host.StartAsync(); + + using (var client = host.NewClient()) + { + for (var i = 0; i < 5000; i++) + { + using var response = await client.GetAsync("/auth/heimdall/discord"); + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + } + } + + Assert.True((await Genuine(host, genuine, accountId)).SignedIn); + } + + [Fact] + public async Task Two_attempts_in_one_browser_both_complete() + { + using var host = new HeimdallHost(); + var first = HeimdallShapes.NewAccountId(); + var second = HeimdallShapes.NewAccountId(); + var attemptOne = await host.StartAsync(returnUrl: "/App/One"); + var attemptTwo = await host.StartAsync(returnUrl: "/App/Two"); + + var doneTwo = await Genuine(host, attemptTwo, second); + var doneOne = await Genuine(host, attemptOne, first); + + Assert.True(doneTwo.SignedIn); + Assert.Equal("/App/Two", doneTwo.Response.Headers.Location!.OriginalString); + Assert.True(doneOne.SignedIn); + Assert.Equal("/App/One", doneOne.Response.Headers.Location!.OriginalString); + } + + [Fact] + public async Task Start_sets_a_private_per_attempt_cookie_that_reveals_nothing() + { + using var host = new HeimdallHost(); + var first = await host.StartAsync(returnUrl: "/App/Mill"); + var second = await host.StartAsync(returnUrl: "/App/Mill"); + + Assert.Equal(HttpStatusCode.Redirect, first.Response.StatusCode); + Assert.Equal("https://heimdall.test/oauth/discord/authorize?state=fixture", first.Response.Headers.Location!.OriginalString); + Assert.Equal(43, first.Nonce.Length); + Assert.NotEqual(first.Nonce, second.Nonce); + var header = HeimdallHost.SetCookieHeader(first.Response, "bifrost_heimdall_" + first.Nonce); + Assert.NotNull(header); + var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray(); + Assert.Contains("httponly", attributes); + Assert.Contains("secure", attributes); + Assert.Contains("samesite=lax", attributes); + Assert.Contains("path=/auth/heimdall", attributes); + Assert.Contains("max-age=600", attributes); + Assert.NotEqual(first.Cookie, second.Cookie); + Assert.DoesNotContain(first.Nonce, first.Cookie!); + Assert.DoesNotContain("Mill", first.Cookie!); + Assert.DoesNotContain("Mill", Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(first.Cookie!))); + + using var body = JsonDocument.Parse(host.Heimdall.StartBodies[0]); + var handoff = body.RootElement.GetProperty("handoff"); + Assert.Equal("browser_completion", handoff.GetProperty("kind").GetString()); + Assert.Single(handoff.EnumerateObject()); + Assert.Equal($"https://localhost/auth/heimdall/complete?attempt={first.Nonce}", body.RootElement.GetProperty("returnTo").GetString()); + Assert.Equal("bifrost", body.RootElement.GetProperty("appSlug").GetString()); + Assert.Equal("sign_in", body.RootElement.GetProperty("mode").GetString()); + Assert.False(body.RootElement.TryGetProperty("entitlementPolicy", out _)); + } + + [Fact] + public async Task The_complete_page_holds_no_code_and_leaks_no_referrer() + { + using var host = new HeimdallHost(); + using var client = host.NewClient(); + + using var response = await client.GetAsync("/auth/heimdall/complete?attempt=CANARYATTEMPT&code=CANARYCODE"); + var page = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType!.ToString()); + Assert.Equal("no-store", response.Headers.CacheControl!.ToString()); + Assert.Equal("no-referrer", Assert.Single(response.Headers.GetValues("Referrer-Policy"))); + Assert.Contains("location.hash", page); + Assert.Contains("heimdall_completion_code", page); + Assert.Contains("history.replaceState", page); + Assert.Contains("form.submit()", page); + Assert.Contains("method=\"post\" action=\"/auth/heimdall/complete\"", page); + Assert.Contains("