2026-04-21 20:08:49 +01:00
|
|
|
using System.Net.Http.Headers;
|
|
|
|
|
using System.Security.Claims;
|
|
|
|
|
using System.Text.Json;
|
2026-06-09 20:27:46 +01:00
|
|
|
using System.Text.Json.Serialization;
|
2026-04-21 20:08:49 +01:00
|
|
|
using Bifrost.Web.Configuration;
|
|
|
|
|
using Bifrost.Web.Data;
|
2026-06-17 04:03:14 +01:00
|
|
|
using Bifrost.Web.Domain;
|
|
|
|
|
using Bifrost.Web.Features.Bridge;
|
2026-10-10 06:31:43 +02:00
|
|
|
using Bifrost.Web.Features.ConceptVotes;
|
2026-11-11 01:02:36 +00:00
|
|
|
using Bifrost.Web.Features.Desk;
|
2026-04-22 09:50:31 +01:00
|
|
|
using Bifrost.Web.Features.GitHub;
|
2026-06-08 21:04:51 +01:00
|
|
|
using Bifrost.Web.Features.Heimdall;
|
2026-04-21 20:08:49 +01:00
|
|
|
using Bifrost.Web.Features.Membership;
|
2026-06-09 20:27:46 +01:00
|
|
|
using Bifrost.Web.Features.Motions;
|
2026-10-27 04:57:16 +01:00
|
|
|
using Bifrost.Web.Features.Oidc;
|
2026-06-09 16:28:30 +01:00
|
|
|
using Bifrost.Web.Features.Patronage;
|
2026-11-11 08:49:19 +00:00
|
|
|
using Bifrost.Web.Features.Registration;
|
2026-04-21 20:08:49 +01:00
|
|
|
using Bifrost.Web.Features.Shared;
|
|
|
|
|
using Microsoft.AspNetCore.Authentication;
|
|
|
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
|
|
|
using Microsoft.AspNetCore.Authentication.OAuth;
|
|
|
|
|
using Microsoft.AspNetCore.Authorization;
|
2026-05-20 19:07:45 +01:00
|
|
|
using Microsoft.AspNetCore.DataProtection;
|
2026-10-10 05:26:38 +02:00
|
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
2026-04-22 09:50:31 +01:00
|
|
|
using Microsoft.AspNetCore.HttpLogging;
|
2026-04-21 20:08:49 +01:00
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
using Microsoft.Extensions.Options;
|
2026-10-10 05:26:38 +02:00
|
|
|
using System.Net;
|
2026-04-21 20:08:49 +01:00
|
|
|
|
2026-10-10 19:58:04 +02:00
|
|
|
// Host command: registers Mill concept subjects with configuration and the database only; no web app, no migrations.
|
|
|
|
|
if (args.Length > 0 && args[0] == "concepts")
|
|
|
|
|
{
|
|
|
|
|
var host = Host.CreateApplicationBuilder([]);
|
|
|
|
|
host.Services.AddDbContext<BifrostDbContext>(options => options.UseNpgsql(BifrostConnectionString(host.Configuration)));
|
|
|
|
|
host.Services.AddSingleton(TimeProvider.System);
|
|
|
|
|
host.Services.AddScoped<AuditTrailService>();
|
|
|
|
|
host.Services.AddScoped<ConceptSubjectRegistry>();
|
|
|
|
|
using var commandHost = host.Build();
|
|
|
|
|
Environment.ExitCode = await ConceptCommand.RunAsync(args[1..], commandHost.Services, Console.Out);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static string BifrostConnectionString(IConfiguration configuration) =>
|
|
|
|
|
configuration.GetConnectionString("Bifrost")
|
|
|
|
|
?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres";
|
|
|
|
|
|
2026-04-21 20:08:49 +01:00
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
|
|
|
|
|
|
builder.Services
|
|
|
|
|
.AddOptions<GitHubOAuthOptions>()
|
|
|
|
|
.BindConfiguration(GitHubOAuthOptions.SectionName);
|
|
|
|
|
|
2026-04-22 09:50:31 +01:00
|
|
|
builder.Services
|
|
|
|
|
.AddOptions<GitHubAppOptions>()
|
|
|
|
|
.BindConfiguration(GitHubAppOptions.SectionName);
|
|
|
|
|
|
2026-04-21 20:08:49 +01:00
|
|
|
builder.Services
|
|
|
|
|
.AddOptions<BootstrapOptions>()
|
|
|
|
|
.BindConfiguration(BootstrapOptions.SectionName);
|
|
|
|
|
|
2026-04-22 09:50:31 +01:00
|
|
|
builder.Services
|
|
|
|
|
.AddOptions<BifrostHostOptions>()
|
|
|
|
|
.BindConfiguration(BifrostHostOptions.SectionName);
|
|
|
|
|
|
2026-06-08 21:04:51 +01:00
|
|
|
builder.Services
|
|
|
|
|
.AddOptions<HeimdallOptions>()
|
|
|
|
|
.BindConfiguration(HeimdallOptions.SectionName);
|
|
|
|
|
|
2026-06-22 14:08:35 +01:00
|
|
|
builder.Services
|
|
|
|
|
.AddOptions<StripeOptions>()
|
|
|
|
|
.BindConfiguration(StripeOptions.SectionName);
|
|
|
|
|
|
2026-06-17 04:03:14 +01:00
|
|
|
builder.Services
|
|
|
|
|
.AddOptions<BridgeOptions>()
|
|
|
|
|
.BindConfiguration(BridgeOptions.SectionName);
|
|
|
|
|
|
2026-10-10 06:31:43 +02:00
|
|
|
builder.Services
|
|
|
|
|
.AddOptions<ConceptVoteOptions>()
|
|
|
|
|
.BindConfiguration(ConceptVoteOptions.SectionName);
|
|
|
|
|
|
2026-11-11 01:02:36 +00:00
|
|
|
builder.Services
|
|
|
|
|
.AddOptions<DeskOptions>()
|
|
|
|
|
.BindConfiguration(DeskOptions.SectionName);
|
|
|
|
|
|
2026-11-07 18:42:43 +00:00
|
|
|
builder.Services.AddAntiforgery(options =>
|
|
|
|
|
{
|
|
|
|
|
options.Cookie.Name = BifrostCookies.Antiforgery;
|
|
|
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
|
|
|
|
options.Cookie.Path = BifrostCookies.Path;
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-21 20:08:49 +01:00
|
|
|
builder.Services.AddRazorPages(options =>
|
|
|
|
|
{
|
|
|
|
|
options.Conventions.AuthorizePage("/Membership/Status");
|
|
|
|
|
options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName);
|
2026-10-10 22:48:04 +02:00
|
|
|
options.Conventions.AllowAnonymousToFolder("/Mill");
|
2026-04-21 20:08:49 +01:00
|
|
|
});
|
2026-06-09 20:27:46 +01:00
|
|
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
|
|
|
|
{
|
|
|
|
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
|
|
|
|
});
|
2026-04-21 20:08:49 +01:00
|
|
|
|
|
|
|
|
builder.Services.AddDbContext<BifrostDbContext>(options =>
|
|
|
|
|
{
|
2026-10-10 19:58:04 +02:00
|
|
|
options.UseNpgsql(BifrostConnectionString(builder.Configuration));
|
2026-04-21 20:08:49 +01:00
|
|
|
});
|
|
|
|
|
|
2026-04-22 09:50:31 +01:00
|
|
|
builder.Services.AddHttpLogging(options =>
|
|
|
|
|
{
|
|
|
|
|
options.LoggingFields =
|
|
|
|
|
HttpLoggingFields.RequestMethod |
|
|
|
|
|
HttpLoggingFields.RequestPath |
|
|
|
|
|
HttpLoggingFields.ResponseStatusCode |
|
|
|
|
|
HttpLoggingFields.Duration;
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-21 20:08:49 +01:00
|
|
|
builder.Services.AddHttpContextAccessor();
|
|
|
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
|
|
|
builder.Services.AddScoped<ICurrentBifrostActorAccessor, CurrentBifrostActorAccessor>();
|
|
|
|
|
builder.Services.AddScoped<AuditTrailService>();
|
2026-11-11 08:38:09 +00:00
|
|
|
builder.Services.AddScoped<Bifrost.Web.Features.Membership.MembershipSuspensionService>();
|
2026-04-21 20:08:49 +01:00
|
|
|
builder.Services.AddScoped<DashboardSnapshotService>();
|
2026-06-17 04:03:14 +01:00
|
|
|
builder.Services.AddScoped<BridgeActionService>();
|
|
|
|
|
builder.Services.AddScoped<AgentDispatchRunService>();
|
2026-06-17 04:49:22 +01:00
|
|
|
builder.Services.AddScoped<AgentTransportReceiptService>();
|
2026-06-17 05:21:29 +01:00
|
|
|
builder.Services.AddScoped<GovernanceActivityReceiptService>();
|
2026-04-21 20:08:49 +01:00
|
|
|
builder.Services.AddScoped<MembershipSynchronizationService>();
|
2026-06-22 15:07:22 +01:00
|
|
|
builder.Services.AddScoped<BifrostIdentityService>();
|
2026-04-22 09:50:31 +01:00
|
|
|
builder.Services.AddScoped<GitHubWebhookService>();
|
2026-06-09 20:27:46 +01:00
|
|
|
builder.Services.AddScoped<MotionGovernanceService>();
|
2026-11-09 20:24:49 +00:00
|
|
|
builder.Services.AddScoped<SealedBallotService>();
|
2026-11-09 20:37:47 +00:00
|
|
|
builder.Services.AddScoped<MindIntakeService>();
|
2026-11-09 20:50:41 +00:00
|
|
|
builder.Services.AddOptions<MindIntakeOptions>().BindConfiguration(MindIntakeOptions.SectionName);
|
|
|
|
|
builder.Services.AddSingleton<MindIntakeRateLimit>(); // TimeProvider and options resolve from the container
|
2026-11-11 08:49:19 +00:00
|
|
|
builder.Services.AddOptions<RegistrationOptions>().BindConfiguration(RegistrationOptions.SectionName);
|
|
|
|
|
builder.Services.AddSingleton<RegistrationChallengeService>();
|
2026-11-11 09:34:44 +00:00
|
|
|
builder.Services.AddSingleton<RegistrationRateLimit>();
|
2026-11-09 20:30:35 +00:00
|
|
|
builder.Services.AddHostedService<SealedBallotCloser>();
|
2026-06-09 20:27:46 +01:00
|
|
|
builder.Services.AddScoped<MotionEveSurfaceService>();
|
2026-06-09 16:28:30 +01:00
|
|
|
builder.Services.AddScoped<PatronageService>();
|
2026-06-22 14:08:35 +01:00
|
|
|
builder.Services.AddScoped<StripeWebhookService>();
|
2026-06-09 17:16:27 +01:00
|
|
|
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
|
2026-10-10 06:31:43 +02:00
|
|
|
builder.Services.AddScoped<ConceptAppraisalService>();
|
2026-10-10 22:48:04 +02:00
|
|
|
builder.Services.AddScoped<VoterKeyResolver>();
|
2026-10-11 09:00:23 +02:00
|
|
|
builder.Services.AddSingleton<ConceptVoteRateLimits>();
|
2026-10-10 19:58:04 +02:00
|
|
|
builder.Services.AddScoped<ConceptSubjectRegistry>();
|
2026-04-22 09:50:31 +01:00
|
|
|
builder.Services.AddScoped<ReadinessService>();
|
|
|
|
|
builder.Services.AddScoped<ApplicationBootstrapper>();
|
|
|
|
|
builder.Services.AddSingleton<StartupConfigurationValidator>();
|
2026-04-21 20:08:49 +01:00
|
|
|
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
|
2026-06-08 21:04:51 +01:00
|
|
|
builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
|
2026-10-11 08:58:19 +02:00
|
|
|
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
|
|
|
|
|
.RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase));
|
2026-10-11 02:55:35 +02:00
|
|
|
builder.Services.AddSingleton<HeimdallTokenVerifier>();
|
|
|
|
|
builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) =>
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value));
|
2026-06-22 14:08:35 +01:00
|
|
|
builder.Services.AddHttpClient<StripeCheckoutService>(client =>
|
|
|
|
|
{
|
|
|
|
|
client.BaseAddress = new Uri("https://api.stripe.com/");
|
|
|
|
|
});
|
2026-04-21 20:08:49 +01:00
|
|
|
|
2026-10-10 05:26:38 +02:00
|
|
|
var dataProtectionBuilder = builder.Services
|
|
|
|
|
.AddDataProtection()
|
|
|
|
|
.SetApplicationName("Bifrost");
|
2026-05-20 19:07:45 +01:00
|
|
|
var dataProtectionKeysPath = builder.Configuration["DataProtection:KeysPath"];
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(dataProtectionKeysPath))
|
|
|
|
|
{
|
2026-10-10 05:26:38 +02:00
|
|
|
dataProtectionBuilder.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath));
|
2026-05-20 19:07:45 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-10 05:26:38 +02:00
|
|
|
// nginx on this host is the only proxy, and it connects from loopback. Trusting exactly
|
|
|
|
|
// those addresses keeps a direct client from choosing its own address with X-Forwarded-For.
|
|
|
|
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
|
|
|
{
|
|
|
|
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
|
|
|
|
options.KnownNetworks.Clear();
|
|
|
|
|
options.KnownProxies.Clear();
|
|
|
|
|
options.KnownProxies.Add(IPAddress.Loopback);
|
|
|
|
|
options.KnownProxies.Add(IPAddress.IPv6Loopback);
|
|
|
|
|
options.KnownProxies.Add(IPAddress.Loopback.MapToIPv6());
|
|
|
|
|
options.ForwardLimit = 1;
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-20 19:07:45 +01:00
|
|
|
var configuredGitHubOAuthOptions = builder.Configuration
|
|
|
|
|
.GetSection(GitHubOAuthOptions.SectionName)
|
|
|
|
|
.Get<GitHubOAuthOptions>() ?? new GitHubOAuthOptions();
|
|
|
|
|
|
|
|
|
|
var authenticationBuilder = builder.Services
|
2026-04-21 20:08:49 +01:00
|
|
|
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
|
|
|
.AddCookie(options =>
|
|
|
|
|
{
|
2026-11-07 18:42:43 +00:00
|
|
|
options.Cookie.Name = BifrostCookies.Session;
|
|
|
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
|
|
|
|
options.Cookie.Path = BifrostCookies.Path;
|
|
|
|
|
options.Cookie.Domain = null;
|
2026-04-21 20:08:49 +01:00
|
|
|
options.LoginPath = "/auth/sign-in";
|
|
|
|
|
options.AccessDeniedPath = "/Membership/Status";
|
|
|
|
|
options.SlidingExpiration = true;
|
2026-05-20 19:07:45 +01:00
|
|
|
});
|
2026-04-21 20:08:49 +01:00
|
|
|
|
2026-05-20 19:07:45 +01:00
|
|
|
if (configuredGitHubOAuthOptions.IsConfigured)
|
|
|
|
|
{
|
|
|
|
|
authenticationBuilder.AddOAuth(GitHubAuthenticationDefaults.AuthenticationScheme, options =>
|
|
|
|
|
{
|
|
|
|
|
options.ClientId = configuredGitHubOAuthOptions.ClientId;
|
|
|
|
|
options.ClientSecret = configuredGitHubOAuthOptions.ClientSecret;
|
|
|
|
|
options.CallbackPath = configuredGitHubOAuthOptions.CallbackPath;
|
2026-04-21 20:08:49 +01:00
|
|
|
options.AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
|
|
|
|
|
options.TokenEndpoint = "https://github.com/login/oauth/access_token";
|
|
|
|
|
options.UserInformationEndpoint = "https://api.github.com/user";
|
|
|
|
|
options.SaveTokens = true;
|
2026-11-07 18:42:43 +00:00
|
|
|
options.CorrelationCookie.Name = BifrostCookies.OAuthCorrelation;
|
|
|
|
|
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;
|
|
|
|
|
options.CorrelationCookie.Path = BifrostCookies.Path;
|
2026-04-21 20:08:49 +01:00
|
|
|
options.Scope.Add("read:user");
|
|
|
|
|
options.Scope.Add("user:email");
|
|
|
|
|
options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
|
|
|
|
|
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "login");
|
|
|
|
|
options.ClaimActions.MapJsonKey(BifrostClaimTypes.GitHubLogin, "login");
|
|
|
|
|
options.ClaimActions.MapJsonKey(BifrostClaimTypes.DisplayName, "name");
|
|
|
|
|
options.ClaimActions.MapJsonKey(BifrostClaimTypes.AvatarUrl, "avatar_url");
|
|
|
|
|
|
|
|
|
|
options.Events = new OAuthEvents
|
|
|
|
|
{
|
|
|
|
|
OnCreatingTicket = async context =>
|
|
|
|
|
{
|
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
|
|
|
|
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
|
|
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);
|
|
|
|
|
request.Headers.UserAgent.ParseAdd("Bifrost");
|
|
|
|
|
|
|
|
|
|
using var response = await context.Backchannel.SendAsync(
|
|
|
|
|
request,
|
|
|
|
|
HttpCompletionOption.ResponseHeadersRead,
|
|
|
|
|
context.HttpContext.RequestAborted);
|
|
|
|
|
|
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
|
|
|
|
|
|
await using var payloadStream = await response.Content.ReadAsStreamAsync(context.HttpContext.RequestAborted);
|
|
|
|
|
using var payload = await JsonDocument.ParseAsync(payloadStream, cancellationToken: context.HttpContext.RequestAborted);
|
|
|
|
|
context.RunClaimActions(payload.RootElement);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-05-20 19:07:45 +01:00
|
|
|
}
|
2026-04-21 20:08:49 +01:00
|
|
|
|
|
|
|
|
builder.Services.AddAuthorization(options =>
|
|
|
|
|
{
|
|
|
|
|
options.AddPolicy(ActiveMemberRequirement.PolicyName, policy =>
|
|
|
|
|
{
|
|
|
|
|
policy.RequireAuthenticatedUser();
|
|
|
|
|
policy.AddRequirements(new ActiveMemberRequirement());
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-10 20:20:13 +02:00
|
|
|
builder.Services.AddConceptTallyCors();
|
2026-10-10 19:59:51 +02:00
|
|
|
|
2026-10-27 04:57:16 +01:00
|
|
|
// The OIDC provider is mapped only when Bifrost:Oidc is configured; a configured provider that cannot load its keys stops startup.
|
|
|
|
|
var oidcOptions = OidcIssuerOptions.FromConfiguration(builder.Configuration);
|
|
|
|
|
if (oidcOptions is not null)
|
|
|
|
|
{
|
|
|
|
|
builder.Services.AddBifrostOidc(oidcOptions);
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-11 01:02:36 +00:00
|
|
|
// The desk is mapped only when the Desk section exists, and a configured desk needs its credential and door ports.
|
|
|
|
|
var deskConfigured = builder.Configuration.GetSection(DeskOptions.SectionName).Exists();
|
|
|
|
|
if (deskConfigured)
|
|
|
|
|
{
|
|
|
|
|
builder.Services.AddBifrostDesk();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 20:08:49 +01:00
|
|
|
var app = builder.Build();
|
2026-06-17 04:03:14 +01:00
|
|
|
var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
|
2026-04-21 20:08:49 +01:00
|
|
|
|
2026-04-22 09:50:31 +01:00
|
|
|
app.Services.GetRequiredService<StartupConfigurationValidator>().Validate();
|
|
|
|
|
await using (var scope = app.Services.CreateAsyncScope())
|
|
|
|
|
{
|
|
|
|
|
await scope.ServiceProvider
|
|
|
|
|
.GetRequiredService<ApplicationBootstrapper>()
|
|
|
|
|
.RunAsync(CancellationToken.None);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 05:26:38 +02:00
|
|
|
app.UseForwardedHeaders();
|
|
|
|
|
|
2026-04-21 20:08:49 +01:00
|
|
|
if (!app.Environment.IsDevelopment())
|
|
|
|
|
{
|
|
|
|
|
app.UseExceptionHandler("/Error");
|
|
|
|
|
app.UseHsts();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 09:50:31 +01:00
|
|
|
app.UseHttpLogging();
|
2026-04-21 20:08:49 +01:00
|
|
|
app.UseHttpsRedirection();
|
2026-11-11 08:12:48 +00:00
|
|
|
if (oidcOptions is not null)
|
|
|
|
|
{
|
|
|
|
|
app.UseMiddleware<OidcGuardMiddleware>(); // before authentication: OpenIddict answers /connect/* inside it
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 20:08:49 +01:00
|
|
|
app.UseStaticFiles();
|
|
|
|
|
app.UseRouting();
|
2026-10-10 21:46:51 +02:00
|
|
|
app.UseConceptTallyCors();
|
2026-04-21 20:08:49 +01:00
|
|
|
app.UseAuthentication();
|
|
|
|
|
app.UseMiddleware<GitHubMembershipSynchronizationMiddleware>();
|
|
|
|
|
app.UseAuthorization();
|
|
|
|
|
|
2026-04-22 09:50:31 +01:00
|
|
|
app.MapGet("/healthz", () => Results.Text("ok")).AllowAnonymous();
|
|
|
|
|
|
|
|
|
|
app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToken cancellationToken) =>
|
|
|
|
|
{
|
|
|
|
|
var report = await readinessService.GetReportAsync(cancellationToken);
|
|
|
|
|
return report.IsReady
|
|
|
|
|
? Results.Text("ready")
|
|
|
|
|
: Results.Text(
|
|
|
|
|
string.Join(Environment.NewLine, report.Failures),
|
|
|
|
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
|
|
|
}).AllowAnonymous();
|
|
|
|
|
|
2026-10-10 20:20:13 +02:00
|
|
|
app.MapConceptTallyExport();
|
2026-11-10 21:06:56 +00:00
|
|
|
app.MapConceptRankedExport();
|
2026-10-10 19:59:51 +02:00
|
|
|
|
2026-10-27 04:57:16 +01:00
|
|
|
if (oidcOptions is not null)
|
|
|
|
|
{
|
|
|
|
|
app.MapBifrostOidc();
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-11 01:02:36 +00:00
|
|
|
if (deskConfigured)
|
|
|
|
|
{
|
|
|
|
|
app.MapDesk();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 09:50:31 +01:00
|
|
|
app.MapPost("/github/webhooks", async (
|
|
|
|
|
HttpRequest request,
|
|
|
|
|
GitHubWebhookService gitHubWebhookService,
|
|
|
|
|
CancellationToken cancellationToken) =>
|
|
|
|
|
{
|
|
|
|
|
using var reader = new StreamReader(request.Body);
|
|
|
|
|
var payload = await reader.ReadToEndAsync(cancellationToken);
|
|
|
|
|
var eventName = request.Headers["X-GitHub-Event"].ToString();
|
|
|
|
|
var deliveryId = request.Headers["X-GitHub-Delivery"].ToString();
|
|
|
|
|
var signature = request.Headers["X-Hub-Signature-256"].ToString();
|
|
|
|
|
|
|
|
|
|
var result = await gitHubWebhookService.ProcessAsync(
|
|
|
|
|
eventName,
|
|
|
|
|
deliveryId,
|
|
|
|
|
signature,
|
|
|
|
|
payload,
|
|
|
|
|
cancellationToken);
|
|
|
|
|
|
|
|
|
|
return result.Status switch
|
|
|
|
|
{
|
|
|
|
|
GitHubWebhookProcessStatus.Processed => Results.Text(result.Message, statusCode: StatusCodes.Status202Accepted),
|
|
|
|
|
GitHubWebhookProcessStatus.Ignored => Results.Text(result.Message),
|
|
|
|
|
GitHubWebhookProcessStatus.NotConfigured => Results.Text(
|
|
|
|
|
result.Message,
|
|
|
|
|
statusCode: StatusCodes.Status503ServiceUnavailable),
|
|
|
|
|
_ => Results.Text(
|
|
|
|
|
result.Message,
|
|
|
|
|
statusCode: StatusCodes.Status401Unauthorized)
|
|
|
|
|
};
|
|
|
|
|
}).AllowAnonymous();
|
|
|
|
|
|
2026-04-21 20:08:49 +01:00
|
|
|
app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuthOptions> gitHubOptions) =>
|
|
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString());
|
2026-06-22 14:08:35 +01:00
|
|
|
|
|
|
|
|
var provider = httpContext.Request.Query["provider"].ToString();
|
|
|
|
|
if (string.IsNullOrWhiteSpace(provider))
|
|
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
await WriteSignInChooserAsync(httpContext, returnPath.Value);
|
2026-04-21 20:08:49 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 14:08:35 +01:00
|
|
|
if (!provider.Equals("github", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnPath.Value)}");
|
2026-06-22 14:08:35 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!gitHubOptions.Value.IsConfigured)
|
2026-04-21 20:08:49 +01:00
|
|
|
{
|
2026-06-22 14:08:35 +01:00
|
|
|
httpContext.Response.Redirect("/?auth=github-not-configured");
|
|
|
|
|
return;
|
2026-04-21 20:08:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await httpContext.ChallengeAsync(
|
|
|
|
|
GitHubAuthenticationDefaults.AuthenticationScheme,
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
new() { RedirectUri = returnPath.Value });
|
2026-04-21 20:08:49 +01:00
|
|
|
}).AllowAnonymous();
|
|
|
|
|
|
2026-06-22 15:07:22 +01:00
|
|
|
app.MapPost("/auth/bifrost/register", async (
|
|
|
|
|
NativeBifrostRegistrationRequest request,
|
|
|
|
|
HttpContext httpContext,
|
|
|
|
|
BifrostIdentityService bifrostIdentityService,
|
2026-11-11 08:49:19 +00:00
|
|
|
RegistrationChallengeService challenges,
|
2026-11-11 09:34:44 +00:00
|
|
|
RegistrationRateLimit rateLimit,
|
2026-06-22 15:07:22 +01:00
|
|
|
CancellationToken cancellationToken) =>
|
|
|
|
|
{
|
2026-11-11 09:34:44 +00:00
|
|
|
if (rateLimit.Refuse(httpContext) is { } refused)
|
|
|
|
|
{
|
|
|
|
|
return refused;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-11 08:49:19 +00:00
|
|
|
// Consent first, then the proof of work; a refusal here creates nothing.
|
|
|
|
|
if (request.AcceptedNoticeVersion != TrainingNotice.Version)
|
|
|
|
|
{
|
|
|
|
|
return Results.Text(
|
|
|
|
|
$"Registration requires accepting the training notice, current version {TrainingNotice.Version}.",
|
|
|
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!challenges.TryConsume(request.Challenge, request.Nonce))
|
|
|
|
|
{
|
|
|
|
|
return Results.Text("Registration challenge failed.", statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 15:07:22 +01:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var userAccount = await bifrostIdentityService.RegisterNativeAsync(
|
|
|
|
|
request.Identity,
|
|
|
|
|
request.DisplayName,
|
2026-11-11 08:49:19 +00:00
|
|
|
TrainingNotice.Version,
|
2026-06-22 15:07:22 +01:00
|
|
|
cancellationToken);
|
|
|
|
|
await httpContext.SignInAsync(
|
|
|
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
|
|
|
bifrostIdentityService.BuildPrincipal(userAccount));
|
|
|
|
|
|
|
|
|
|
var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl;
|
|
|
|
|
return JsonResult(
|
|
|
|
|
new NativeBifrostRegistrationResponse(
|
|
|
|
|
userAccount.Id,
|
|
|
|
|
userAccount.BifrostIdentity,
|
|
|
|
|
userAccount.DisplayName,
|
|
|
|
|
userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(),
|
|
|
|
|
returnUrl),
|
|
|
|
|
appJsonSerializerOptions);
|
|
|
|
|
}
|
|
|
|
|
catch (BifrostIdentityException error)
|
|
|
|
|
{
|
|
|
|
|
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
|
}
|
|
|
|
|
}).AllowAnonymous();
|
|
|
|
|
|
2026-11-11 09:34:44 +00:00
|
|
|
app.MapGet("/auth/bifrost/challenge", (HttpContext httpContext, RegistrationChallengeService challenges, RegistrationRateLimit rateLimit) =>
|
2026-11-11 08:49:19 +00:00
|
|
|
{
|
2026-11-11 09:34:44 +00:00
|
|
|
if (rateLimit.Refuse(httpContext) is { } refused)
|
|
|
|
|
{
|
|
|
|
|
return refused;
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-11 08:49:19 +00:00
|
|
|
httpContext.Response.Headers.CacheControl = "no-store";
|
|
|
|
|
var challenge = challenges.Issue();
|
|
|
|
|
return challenge is null
|
|
|
|
|
? Results.Text("Registration is busy; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable)
|
|
|
|
|
: JsonResult(challenge, appJsonSerializerOptions);
|
|
|
|
|
}).AllowAnonymous();
|
|
|
|
|
|
|
|
|
|
app.MapGet("/auth/bifrost/notice", () =>
|
|
|
|
|
JsonResult(new { version = TrainingNotice.Version, text = TrainingNotice.Text }, appJsonSerializerOptions)).AllowAnonymous();
|
|
|
|
|
|
|
|
|
|
// Withdrawal stops future use; the receipt row stays. Idempotent: an already-withdrawn receipt keeps its first timestamp.
|
|
|
|
|
app.MapPost("/auth/bifrost/consent/withdraw", async (
|
|
|
|
|
ICurrentBifrostActorAccessor actors,
|
|
|
|
|
BifrostDbContext dbContext,
|
|
|
|
|
TimeProvider clock,
|
|
|
|
|
CancellationToken cancellationToken) =>
|
|
|
|
|
{
|
|
|
|
|
var actor = await actors.GetAsync(cancellationToken);
|
|
|
|
|
if (!actor.IsAuthenticated || actor.UserAccount is null)
|
|
|
|
|
{
|
|
|
|
|
return Results.Unauthorized();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var receipts = await dbContext.ConsentReceipts
|
|
|
|
|
.Where(x => x.UserAccountId == actor.UserAccount.Id)
|
|
|
|
|
.ToListAsync(cancellationToken);
|
2026-11-11 09:34:44 +00:00
|
|
|
if (receipts.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
return Results.Text("No consent receipt is recorded for this account.", statusCode: StatusCodes.Status404NotFound);
|
|
|
|
|
}
|
|
|
|
|
|
2026-11-11 08:49:19 +00:00
|
|
|
var now = clock.GetUtcNow();
|
|
|
|
|
foreach (var receipt in receipts.Where(x => x.WithdrawnAtUtc is null))
|
|
|
|
|
{
|
|
|
|
|
receipt.WithdrawnAtUtc = now;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
|
|
return JsonResult(
|
|
|
|
|
new { withdrawnAtUtc = receipts.Min(x => x.WithdrawnAtUtc) },
|
|
|
|
|
appJsonSerializerOptions);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-08 21:04:51 +01:00
|
|
|
app.MapGet("/auth/heimdall/{provider}", async (
|
|
|
|
|
string provider,
|
|
|
|
|
HttpContext httpContext,
|
|
|
|
|
HeimdallAuthService heimdallAuthService,
|
|
|
|
|
IOptions<BifrostHostOptions> hostOptions,
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
ILogger<HeimdallAuthService> logger,
|
2026-06-08 21:04:51 +01:00
|
|
|
CancellationToken cancellationToken) =>
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString());
|
2026-06-22 14:08:35 +01:00
|
|
|
var requireMemberAccess = string.Equals(
|
|
|
|
|
httpContext.Request.Query["access"].ToString(),
|
|
|
|
|
"member",
|
|
|
|
|
StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
|
2026-06-08 21:04:51 +01:00
|
|
|
var start = await heimdallAuthService.StartAsync(
|
|
|
|
|
provider,
|
|
|
|
|
hostOptions.Value.PublicBaseUrl,
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
returnPath,
|
2026-06-22 14:08:35 +01:00
|
|
|
requireMemberAccess,
|
2026-10-11 22:25:24 +02:00
|
|
|
httpContext.Request.Cookies,
|
2026-06-08 21:04:51 +01:00
|
|
|
cancellationToken);
|
|
|
|
|
|
2026-10-11 04:41:36 +02:00
|
|
|
httpContext.Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options());
|
2026-06-08 21:04:51 +01:00
|
|
|
return Results.Redirect(start.AuthorizationUrl.ToString());
|
|
|
|
|
}
|
|
|
|
|
catch (HeimdallAuthException error)
|
|
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
logger.LogWarning("Heimdall start refused: {Reason}", error.Message);
|
|
|
|
|
return Results.Redirect(HeimdallAuthService.ErrorRedirect);
|
2026-06-08 21:04:51 +01:00
|
|
|
}
|
|
|
|
|
}).AllowAnonymous();
|
|
|
|
|
|
2026-06-09 17:16:27 +01:00
|
|
|
app.MapPost("/heimdall/patron-support/events", async (
|
|
|
|
|
HttpRequest request,
|
|
|
|
|
HeimdallPatronSupportIntakeService intakeService,
|
|
|
|
|
CancellationToken cancellationToken) =>
|
|
|
|
|
{
|
|
|
|
|
using var reader = new StreamReader(request.Body);
|
|
|
|
|
var payload = await reader.ReadToEndAsync(cancellationToken);
|
|
|
|
|
var signature = request.Headers["X-Heimdall-Signature-256"].ToString();
|
|
|
|
|
|
|
|
|
|
var result = await intakeService.ProcessAsync(signature, payload, cancellationToken);
|
|
|
|
|
return result.Status switch
|
|
|
|
|
{
|
|
|
|
|
HeimdallPatronSupportIntakeStatus.Processed => Results.Text(
|
|
|
|
|
result.Message,
|
|
|
|
|
statusCode: StatusCodes.Status202Accepted),
|
|
|
|
|
HeimdallPatronSupportIntakeStatus.BadRequest => Results.BadRequest(result.Message),
|
|
|
|
|
HeimdallPatronSupportIntakeStatus.NotConfigured => Results.Text(
|
|
|
|
|
result.Message,
|
|
|
|
|
statusCode: StatusCodes.Status503ServiceUnavailable),
|
|
|
|
|
_ => Results.Text(result.Message, statusCode: StatusCodes.Status401Unauthorized)
|
|
|
|
|
};
|
|
|
|
|
}).AllowAnonymous();
|
|
|
|
|
|
2026-07-05 19:07:48 +01:00
|
|
|
app.MapGet("/patronage/{project}/checkout", async (
|
|
|
|
|
string project,
|
2026-06-22 14:08:35 +01:00
|
|
|
HttpRequest request,
|
|
|
|
|
ICurrentBifrostActorAccessor actorAccessor,
|
|
|
|
|
StripeCheckoutService stripeCheckoutService,
|
|
|
|
|
CancellationToken cancellationToken) =>
|
|
|
|
|
{
|
2026-07-05 19:07:48 +01:00
|
|
|
var amountCentsText = request.Query["amountCents"].ToString();
|
|
|
|
|
if (!int.TryParse(amountCentsText, out var amountCents))
|
2026-06-22 14:08:35 +01:00
|
|
|
{
|
2026-07-05 19:07:48 +01:00
|
|
|
return Results.Text(
|
|
|
|
|
"Missing or invalid required amountCents query parameter.",
|
|
|
|
|
statusCode: StatusCodes.Status400BadRequest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var item = request.Query["item"].ToString();
|
|
|
|
|
if (string.IsNullOrWhiteSpace(item))
|
|
|
|
|
{
|
|
|
|
|
return Results.Text(
|
|
|
|
|
"Missing required item query parameter.",
|
|
|
|
|
statusCode: StatusCodes.Status400BadRequest);
|
2026-06-22 14:08:35 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var actor = await actorAccessor.GetAsync(cancellationToken);
|
|
|
|
|
if (!actor.IsAuthenticated || actor.UserAccount is null)
|
|
|
|
|
{
|
|
|
|
|
var returnUrl = Uri.EscapeDataString($"{request.Path}{request.QueryString}");
|
|
|
|
|
return Results.Redirect($"/auth/sign-in?returnUrl={returnUrl}");
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 19:07:48 +01:00
|
|
|
var result = await stripeCheckoutService.CreateProjectDonationCheckoutAsync(
|
|
|
|
|
new StripeDonationCheckoutRequest(
|
|
|
|
|
project,
|
|
|
|
|
request.Query["project"].ToString(),
|
|
|
|
|
item,
|
|
|
|
|
amountCents,
|
|
|
|
|
request.Query["currency"].ToString(),
|
|
|
|
|
request.Query["source"].ToString()),
|
2026-06-22 14:08:35 +01:00
|
|
|
actor.UserAccount,
|
|
|
|
|
cancellationToken);
|
|
|
|
|
return result.Status switch
|
|
|
|
|
{
|
|
|
|
|
StripeCheckoutStatus.Created => Results.Redirect(result.CheckoutUrl!.ToString()),
|
2026-07-05 19:07:48 +01:00
|
|
|
StripeCheckoutStatus.InvalidRequest => Results.Text(
|
|
|
|
|
result.Message,
|
|
|
|
|
statusCode: StatusCodes.Status400BadRequest),
|
|
|
|
|
StripeCheckoutStatus.UnknownProject => Results.Text(
|
|
|
|
|
result.Message,
|
|
|
|
|
statusCode: StatusCodes.Status404NotFound),
|
2026-06-22 14:08:35 +01:00
|
|
|
StripeCheckoutStatus.NotConfigured => Results.Text(
|
|
|
|
|
result.Message,
|
|
|
|
|
statusCode: StatusCodes.Status503ServiceUnavailable),
|
|
|
|
|
_ => Results.Text(
|
|
|
|
|
result.Message,
|
|
|
|
|
statusCode: StatusCodes.Status502BadGateway)
|
|
|
|
|
};
|
|
|
|
|
}).AllowAnonymous();
|
|
|
|
|
|
|
|
|
|
app.MapPost("/patronage/stripe/webhook", async (
|
|
|
|
|
HttpRequest request,
|
|
|
|
|
StripeWebhookService webhookService,
|
|
|
|
|
CancellationToken cancellationToken) =>
|
|
|
|
|
{
|
|
|
|
|
using var reader = new StreamReader(request.Body);
|
|
|
|
|
var payload = await reader.ReadToEndAsync(cancellationToken);
|
|
|
|
|
var signature = request.Headers["Stripe-Signature"].ToString();
|
|
|
|
|
|
|
|
|
|
var result = await webhookService.ProcessAsync(signature, payload, cancellationToken);
|
|
|
|
|
return result.Status switch
|
|
|
|
|
{
|
|
|
|
|
StripeWebhookStatus.Processed => Results.Text(
|
|
|
|
|
result.Message,
|
|
|
|
|
statusCode: StatusCodes.Status202Accepted),
|
|
|
|
|
StripeWebhookStatus.Ignored => Results.Text(result.Message),
|
|
|
|
|
StripeWebhookStatus.BadRequest => Results.BadRequest(result.Message),
|
|
|
|
|
StripeWebhookStatus.NotConfigured => Results.Text(
|
|
|
|
|
result.Message,
|
|
|
|
|
statusCode: StatusCodes.Status503ServiceUnavailable),
|
|
|
|
|
_ => Results.Text(result.Message, statusCode: StatusCodes.Status401Unauthorized)
|
|
|
|
|
};
|
|
|
|
|
}).AllowAnonymous();
|
|
|
|
|
|
2026-06-09 20:27:46 +01:00
|
|
|
var eveGovernance = app.MapGroup("/eve/governance")
|
|
|
|
|
.RequireAuthorization(ActiveMemberRequirement.PolicyName);
|
|
|
|
|
|
|
|
|
|
eveGovernance.MapGet("/surface", async (
|
|
|
|
|
ICurrentBifrostActorAccessor actorAccessor,
|
|
|
|
|
MotionGovernanceService motionGovernanceService,
|
|
|
|
|
MotionEveSurfaceService motionEveSurfaceService,
|
|
|
|
|
CancellationToken cancellationToken) =>
|
|
|
|
|
{
|
|
|
|
|
var actor = await actorAccessor.GetAsync(cancellationToken);
|
|
|
|
|
var state = await motionGovernanceService.GetStateAsync(actor, cancellationToken);
|
|
|
|
|
var surface = motionEveSurfaceService.BuildSurface(state);
|
|
|
|
|
return Results.Text(
|
2026-06-17 04:03:14 +01:00
|
|
|
JsonSerializer.Serialize(surface, appJsonSerializerOptions),
|
2026-06-09 20:27:46 +01:00
|
|
|
"application/json");
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-09 20:24:49 +00:00
|
|
|
// Sealed-ballot intake. Typed JSON documents posted directly by the voter; no ballot content ever rides in a room message,
|
|
|
|
|
// and no response before close carries another member's ballot.
|
|
|
|
|
var sealedMotions = app.MapGroup("/api/sealed-motions")
|
|
|
|
|
.RequireAuthorization(ActiveMemberRequirement.PolicyName);
|
|
|
|
|
|
2026-11-09 20:30:35 +00:00
|
|
|
async Task<IResult> SealedCall(Func<Task<object>> call)
|
2026-11-09 20:24:49 +00:00
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-11-09 20:30:35 +00:00
|
|
|
return Results.Text(JsonSerializer.Serialize(await call(), appJsonSerializerOptions), "application/json");
|
2026-11-09 20:24:49 +00:00
|
|
|
}
|
|
|
|
|
catch (SealedBallotException error)
|
|
|
|
|
{
|
2026-11-09 20:30:35 +00:00
|
|
|
return Results.Text(
|
|
|
|
|
JsonSerializer.Serialize(new { error = error.Message }, appJsonSerializerOptions),
|
|
|
|
|
"application/json",
|
2026-11-09 20:24:49 +00:00
|
|
|
statusCode: error.Failure switch
|
|
|
|
|
{
|
|
|
|
|
SealedBallotFailure.Forbidden => StatusCodes.Status403Forbidden,
|
2026-11-09 20:37:47 +00:00
|
|
|
SealedBallotFailure.Unauthorized => StatusCodes.Status401Unauthorized,
|
2026-11-09 20:24:49 +00:00
|
|
|
SealedBallotFailure.NotFound => StatusCodes.Status404NotFound,
|
|
|
|
|
SealedBallotFailure.WrongPhase => StatusCodes.Status409Conflict,
|
|
|
|
|
_ => StatusCodes.Status400BadRequest
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sealedMotions.MapPost("", (ICurrentBifrostActorAccessor actors, SealedBallotService service, OpenSealedMotionCommand command, CancellationToken ct) =>
|
|
|
|
|
SealedCall(async () => await service.OpenAsync(await actors.GetAsync(ct), command, ct)));
|
|
|
|
|
sealedMotions.MapGet("/{id:guid}", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, CancellationToken ct) =>
|
|
|
|
|
SealedCall(async () => await service.GetStateAsync(await actors.GetAsync(ct), id, ct)));
|
|
|
|
|
sealedMotions.MapPost("/{id:guid}/commit", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, SealedCommitRequest body, CancellationToken ct) =>
|
|
|
|
|
SealedCall(async () => await service.CommitAsync(await actors.GetAsync(ct), id, body.Commitment, ct)));
|
|
|
|
|
sealedMotions.MapPost("/{id:guid}/reveal", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, SealedBallotDocument body, CancellationToken ct) =>
|
|
|
|
|
SealedCall(async () => await service.RevealAsync(await actors.GetAsync(ct), id, body, ct)));
|
|
|
|
|
sealedMotions.MapGet("/{id:guid}/result", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, CancellationToken ct) =>
|
|
|
|
|
SealedCall(async () => await service.GetResultAsync(await actors.GetAsync(ct), id, ct)));
|
|
|
|
|
|
2026-11-09 20:37:47 +00:00
|
|
|
sealedMotions.MapPost("/mind-keys", (ICurrentBifrostActorAccessor actors, MindIntakeService service, RegisterMindKeyRequest body, CancellationToken ct) =>
|
|
|
|
|
SealedCall(async () => await service.RegisterKeyAsync(await actors.GetAsync(ct), body.Persona, body.UserAccountId, body.PublicKey, ct)));
|
|
|
|
|
sealedMotions.MapGet("/mind-keys", (ICurrentBifrostActorAccessor actors, MindIntakeService service, CancellationToken ct) =>
|
|
|
|
|
SealedCall(async () => await service.ListKeysAsync(await actors.GetAsync(ct), ct)));
|
|
|
|
|
sealedMotions.MapDelete("/mind-keys/{keyId:guid}", (ICurrentBifrostActorAccessor actors, MindIntakeService service, Guid keyId, CancellationToken ct) =>
|
|
|
|
|
SealedCall(async () =>
|
|
|
|
|
{
|
|
|
|
|
await service.RevokeKeyAsync(await actors.GetAsync(ct), keyId, ct);
|
|
|
|
|
return new { revoked = keyId };
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Signed Mind intake: the Ed25519 signature is the credential. No session, no cookie; the verified envelope runs the same
|
|
|
|
|
// SealedBallotService verbs as a member cast.
|
2026-11-09 20:50:41 +00:00
|
|
|
app.MapPost("/api/sealed-motions/{id:guid}/mind", async (
|
|
|
|
|
HttpContext http, MindIntakeService service, MindIntakeRateLimit limiter, IOptions<MindIntakeOptions> limits, Guid id, CancellationToken ct) =>
|
|
|
|
|
{
|
|
|
|
|
// Per-source permit and a hard body cap, both before any parsing or database work.
|
|
|
|
|
if (limiter.Spend(http.Connection.RemoteIpAddress?.ToString() ?? "unknown") is { } denied)
|
|
|
|
|
{
|
|
|
|
|
http.Response.Headers.RetryAfter = Math.Ceiling(denied.RetryAfter.TotalSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
|
|
|
|
return Results.Text("{\"error\":\"Too many requests.\"}", "application/json", statusCode: StatusCodes.Status429TooManyRequests);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var max = limits.Value.MaxBodyBytes;
|
|
|
|
|
if (http.Request.ContentLength > max)
|
|
|
|
|
{
|
|
|
|
|
return Results.Text("{\"error\":\"Envelope too large.\"}", "application/json", statusCode: StatusCodes.Status413PayloadTooLarge);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var buffer = new byte[max + 1];
|
|
|
|
|
var read = 0;
|
|
|
|
|
int n;
|
|
|
|
|
while (read < buffer.Length && (n = await http.Request.Body.ReadAsync(buffer.AsMemory(read), ct)) > 0)
|
|
|
|
|
{
|
|
|
|
|
read += n;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (read > max)
|
|
|
|
|
{
|
|
|
|
|
return Results.Text("{\"error\":\"Envelope too large.\"}", "application/json", statusCode: StatusCodes.Status413PayloadTooLarge);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
MindEnvelope? envelope;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
envelope = JsonSerializer.Deserialize<MindEnvelope>(buffer.AsSpan(0, read), new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
|
|
|
|
}
|
|
|
|
|
catch (JsonException)
|
|
|
|
|
{
|
|
|
|
|
envelope = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return envelope is null
|
|
|
|
|
? Results.Text("{\"error\":\"The envelope is malformed.\"}", "application/json", statusCode: StatusCodes.Status400BadRequest)
|
|
|
|
|
: await SealedCall(async () => await service.HandleAsync(id, envelope, ct));
|
|
|
|
|
}).AllowAnonymous();
|
2026-11-09 20:37:47 +00:00
|
|
|
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
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 (
|
2026-06-08 21:04:51 +01:00
|
|
|
HttpContext httpContext,
|
|
|
|
|
HeimdallAuthService heimdallAuthService,
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
IOptions<BifrostHostOptions> hostOptions,
|
|
|
|
|
ILogger<HeimdallAuthService> logger,
|
2026-06-08 21:04:51 +01:00
|
|
|
CancellationToken cancellationToken) =>
|
|
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken);
|
|
|
|
|
var attempt = form?["attempt"].ToString();
|
2026-10-11 22:25:24 +02:00
|
|
|
var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt);
|
2026-06-08 21:04:51 +01:00
|
|
|
try
|
|
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
var signIn = await heimdallAuthService.CompleteAsync(
|
|
|
|
|
hostOptions.Value.PublicBaseUrl,
|
|
|
|
|
attempt,
|
2026-10-11 22:25:24 +02:00
|
|
|
held?.Value,
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
form?["code"].ToString(),
|
2026-10-11 02:54:13 +02:00
|
|
|
cancellationToken);
|
2026-06-08 21:04:51 +01:00
|
|
|
await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal);
|
2026-10-11 22:25:24 +02:00
|
|
|
httpContext.Response.Cookies.Delete(held!.Value.Name, HeimdallAttemptCookie.DeleteOptions());
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
return Results.LocalRedirect(signIn.ReturnPath.Value);
|
2026-06-08 21:04:51 +01:00
|
|
|
}
|
|
|
|
|
catch (HeimdallAuthException error)
|
|
|
|
|
{
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
logger.LogWarning("Heimdall completion refused: {Reason}", error.Message);
|
2026-10-11 22:25:24 +02:00
|
|
|
if (held is not null)
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
{
|
2026-10-11 22:25:24 +02:00
|
|
|
httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions());
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Results.Redirect(HeimdallAuthService.ErrorRedirect);
|
2026-06-08 21:04:51 +01:00
|
|
|
}
|
|
|
|
|
}).AllowAnonymous();
|
|
|
|
|
|
2026-04-21 20:08:49 +01:00
|
|
|
app.MapPost("/auth/sign-out", async (HttpContext httpContext) =>
|
|
|
|
|
{
|
|
|
|
|
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
|
httpContext.Response.Redirect("/");
|
|
|
|
|
}).AllowAnonymous();
|
|
|
|
|
|
|
|
|
|
app.MapRazorPages();
|
|
|
|
|
|
|
|
|
|
app.Run();
|
|
|
|
|
|
2026-06-17 04:03:14 +01:00
|
|
|
static IResult JsonResult<T>(T value, JsonSerializerOptions options, int statusCode = StatusCodes.Status200OK) =>
|
|
|
|
|
Results.Text(
|
|
|
|
|
JsonSerializer.Serialize(value, options),
|
|
|
|
|
"application/json",
|
|
|
|
|
statusCode: statusCode);
|
|
|
|
|
|
Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.
Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.
Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 14:08:35 +01:00
|
|
|
static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl)
|
|
|
|
|
{
|
|
|
|
|
httpContext.Response.ContentType = "text/html; charset=utf-8";
|
|
|
|
|
var encodedReturnUrl = Uri.EscapeDataString(returnUrl);
|
|
|
|
|
var escapedReturnUrl = System.Net.WebUtility.HtmlEncode(returnUrl);
|
|
|
|
|
await httpContext.Response.WriteAsync($$"""
|
|
|
|
|
<!doctype html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8" />
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
|
|
<title>Sign in - Bifrost</title>
|
|
|
|
|
<style>
|
|
|
|
|
body { margin: 0; min-height: 100svh; display: grid; place-items: center; background: #090908; color: #fff8ef; font-family: system-ui, sans-serif; }
|
|
|
|
|
main { width: min(92vw, 520px); padding: 28px; border: 1px solid rgba(255,248,239,.16); border-radius: 8px; background: #141110; }
|
|
|
|
|
h1 { margin: 0 0 10px; font-size: 2rem; line-height: 1; }
|
|
|
|
|
p { color: #c7b8ad; }
|
|
|
|
|
.providers { display: grid; gap: 10px; margin-top: 22px; }
|
|
|
|
|
a { display: flex; justify-content: space-between; align-items: center; min-height: 46px; padding: 0 14px; border-radius: 6px; background: #c43e4f; color: white; text-decoration: none; font-weight: 750; }
|
|
|
|
|
a.secondary { background: rgba(255,248,239,.08); border: 1px solid rgba(255,248,239,.22); }
|
|
|
|
|
small { color: #d9ad66; }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<main>
|
|
|
|
|
<small>Transport identity</small>
|
|
|
|
|
<h1>Create or use a Bifrost patron account</h1>
|
|
|
|
|
<p>Choose a provider to attach this checkout to a Bifrost account. Patron accounts do not automatically grant active member access.</p>
|
|
|
|
|
<div class="providers">
|
|
|
|
|
<a href="/auth/sign-in?provider=github&returnUrl={{encodedReturnUrl}}">GitHub <span>OAuth</span></a>
|
|
|
|
|
<a class="secondary" href="/auth/heimdall/discord?returnUrl={{encodedReturnUrl}}">Discord <span>Heimdall</span></a>
|
|
|
|
|
<a class="secondary" href="/auth/heimdall/patreon?returnUrl={{encodedReturnUrl}}">Patreon <span>Heimdall</span></a>
|
|
|
|
|
<a class="secondary" href="/auth/heimdall/twitch?returnUrl={{encodedReturnUrl}}">Twitch <span>Heimdall</span></a>
|
|
|
|
|
</div>
|
|
|
|
|
<p><small>After sign-in, Bifrost returns you to {{escapedReturnUrl}}.</small></p>
|
|
|
|
|
</main>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
""");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 04:03:14 +01:00
|
|
|
static JsonSerializerOptions CreateAppJsonSerializerOptions()
|
2026-06-09 20:27:46 +01:00
|
|
|
{
|
|
|
|
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
|
|
|
|
options.Converters.Add(new JsonStringEnumConverter());
|
|
|
|
|
return options;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 20:08:49 +01:00
|
|
|
public partial class Program;
|
2026-06-22 15:07:22 +01:00
|
|
|
|
|
|
|
|
public sealed record NativeBifrostRegistrationRequest(
|
|
|
|
|
string Identity,
|
|
|
|
|
string? DisplayName,
|
2026-11-11 08:49:19 +00:00
|
|
|
string? ReturnUrl,
|
|
|
|
|
string? Challenge,
|
|
|
|
|
string? Nonce,
|
|
|
|
|
int? AcceptedNoticeVersion);
|
2026-06-22 15:07:22 +01:00
|
|
|
|
|
|
|
|
public sealed record NativeBifrostRegistrationResponse(
|
|
|
|
|
Guid UserAccountId,
|
|
|
|
|
string BifrostIdentity,
|
|
|
|
|
string DisplayName,
|
|
|
|
|
string MembershipStatus,
|
|
|
|
|
string ReturnUrl);
|