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 10:15:46 +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 > ( )
2026-11-12 13:24:59 +00:00
. BindConfiguration ( HeimdallOptions . SectionName )
. PostConfigure < ILogger < HeimdallOptions > > ( HeimdallOptions . ResolveAppSecret ) ;
2026-06-08 21:04:51 +01:00
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 10:15:46 +00:00
builder . Services . AddOptions < RegistrationOptions > ( ) . BindConfiguration ( RegistrationOptions . SectionName ) ;
builder . Services . AddSingleton < RegistrationChallengeService > ( ) ;
builder . Services . AddSingleton < RegistrationRateLimit > ( ) ;
2026-11-11 11:17:25 +00:00
builder . Services . AddScoped < NativeRegistration > ( ) ;
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 )
{
2026-11-12 10:17:29 +00:00
builder . Services . AddBifrostDesk ( oidcConfigured : oidcOptions is not null ) ;
2026-11-11 01:02:36 +00:00
}
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 ,
2026-11-11 11:17:25 +00:00
NativeRegistration registration ,
RegistrationRateLimit rateLimit ,
CancellationToken cancellationToken ) = >
{
if ( rateLimit . Refuse ( httpContext ) is { } refused )
{
return refused ;
}
var outcome = await registration . RunAsync (
httpContext ,
new RegistrationAttempt ( request . Identity , request . DisplayName , request . Challenge , request . Nonce , request . Waited = = true , request . AcceptedNoticeVersion ) ,
cancellationToken ) ;
if ( outcome . Refusal is not null )
{
return outcome . Refusal ;
}
if ( outcome . Account is not { } userAccount )
{
return Results . Text ( outcome . Message ! , statusCode : outcome . Status ) ;
}
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 ) ;
} ) . AllowAnonymous ( ) ;
// GET issues a challenge, so it spends the same per-address budget as /auth/bifrost/challenge.
app . MapGet ( "/auth/bifrost/register" , (
HttpContext httpContext ,
RegistrationChallengeService challenges ,
RegistrationRateLimit rateLimit ,
IOptions < RegistrationOptions > registrationOptions ) = >
{
if ( rateLimit . Refuse ( httpContext ) is { } refused )
{
return refused ;
}
var returnPath = LocalReturnPath . Parse ( httpContext . Request . Query [ "returnUrl" ] . ToString ( ) ) ;
return RegistrationFormResult ( httpContext , challenges , registrationOptions . Value , new RegistrationPageModel ( returnPath . Value , "" , 0 , registrationOptions . Value . Captcha . FallbackWaitSeconds ) , null , StatusCodes . Status200OK ) ;
} ) . AllowAnonymous ( ) ;
// No antiforgery token or cookie exists for an anonymous visitor, so a cross-site post is refused by origin instead
// (RequestOriginGuard, the same Fetch Metadata check as the vote page, except that a request with no Origin and no
// Sec-Fetch-Site and no session cookie passes: text browsers send neither, and there is no session to replace). Without it a foreign page could post here with
// a challenge of its own and replace the visitor's session cookie with an empty account holding a forged consent.
// Order: origin, then the general address budget, then the body cap, then the form; a refusal sets no cookie.
app . MapPost ( "/auth/bifrost/register/form" , async (
HttpContext httpContext ,
NativeRegistration registration ,
2026-11-11 10:15:46 +00:00
RegistrationChallengeService challenges ,
RegistrationRateLimit rateLimit ,
2026-11-11 11:17:25 +00:00
IOptions < BifrostHostOptions > hostOptions ,
IOptions < RegistrationOptions > registrationOptions ,
2026-06-22 15:07:22 +01:00
CancellationToken cancellationToken ) = >
{
2026-11-11 11:17:25 +00:00
if ( ! RequestOriginGuard . AllowsFormWithoutFetchMetadata ( httpContext . Request , hostOptions . Value ) )
{
return Results . Text ( "Cross-site registration is refused." , statusCode : StatusCodes . Status403Forbidden ) ;
}
2026-11-11 10:15:46 +00:00
if ( rateLimit . Refuse ( httpContext ) is { } refused )
{
return refused ;
}
2026-11-11 11:17:25 +00:00
if ( ! ( httpContext . Request . ContentType ? ? "" ) . StartsWith ( "application/x-www-form-urlencoded" , StringComparison . OrdinalIgnoreCase ) )
2026-11-11 10:15:46 +00:00
{
2026-11-11 11:17:25 +00:00
return Results . Text ( "Send the form as application/x-www-form-urlencoded." , statusCode : StatusCodes . Status415UnsupportedMediaType ) ;
}
// The form is a handful of short fields: refuse a big body before reading any of it.
const int MaxFormBytes = 16 * 1024 ;
if ( httpContext . Request . ContentLength > MaxFormBytes )
{
return Results . Text ( "The form is too large." , statusCode : StatusCodes . Status413PayloadTooLarge ) ;
2026-11-11 10:15:46 +00:00
}
2026-11-11 11:17:25 +00:00
if ( httpContext . Features . Get < Microsoft . AspNetCore . Http . Features . IHttpMaxRequestBodySizeFeature > ( ) is { IsReadOnly : false } limit )
2026-11-11 10:15:46 +00:00
{
2026-11-11 11:17:25 +00:00
limit . MaxRequestBodySize = MaxFormBytes ;
2026-11-11 10:15:46 +00:00
}
2026-11-11 11:17:25 +00:00
IFormCollection form ;
2026-06-22 15:07:22 +01:00
try
{
2026-11-11 11:17:25 +00:00
form = await httpContext . Request . ReadFormAsync ( cancellationToken ) ;
}
catch ( BadHttpRequestException error ) when ( error . StatusCode = = StatusCodes . Status413PayloadTooLarge )
{
return Results . Text ( "The form is too large." , statusCode : StatusCodes . Status413PayloadTooLarge ) ;
}
catch ( Exception error ) when ( error is InvalidDataException or BadHttpRequestException )
{
return Results . Text ( "The form could not be read." , statusCode : StatusCodes . Status400BadRequest ) ;
}
2026-06-22 15:07:22 +01:00
2026-11-11 11:17:25 +00:00
string Field ( string name ) = > form [ name ] . FirstOrDefault ( ) ? ? "" ;
var returnPath = LocalReturnPath . Parse ( Field ( "returnUrl" ) ) ;
2026-11-11 12:11:02 +00:00
// The checkbox is the consent act and is optional: unticked, the posted notice version is ignored and no receipt is made.
// Anything but the canonical decimal of the version in force ("02" included) with the box ticked is sent as -1 and refused, never read as "did not agree".
int? accepted = Field ( "consent" ) ! = "yes"
? null
: Field ( "acceptedNoticeVersion" ) = = TrainingNotice . Version . ToString ( System . Globalization . CultureInfo . InvariantCulture ) ? TrainingNotice . Version : - 1 ;
2026-11-11 11:17:25 +00:00
var identity = Field ( "identity" ) ;
var displayName = Field ( "displayName" ) ;
var challenge = Field ( "challenge" ) ;
var outcome = await registration . RunAsync (
httpContext ,
new RegistrationAttempt ( identity , displayName , challenge , Field ( "nonce" ) , Field ( "waited" ) = = "true" , accepted ) ,
cancellationToken ) ;
if ( outcome . Refusal is not null )
{
return outcome . Refusal ;
2026-06-22 15:07:22 +01:00
}
2026-11-11 11:17:25 +00:00
if ( outcome . Account is not null )
2026-06-22 15:07:22 +01:00
{
2026-11-11 11:17:25 +00:00
// A Location header takes ASCII only; anything else is not a path worth keeping.
var target = returnPath . Value . All ( c = > c > ' ' & & c < ' \ u007f ' ) ? returnPath . Value : LocalReturnPath . Default . Value ;
httpContext . Response . Headers . CacheControl = "no-store" ;
httpContext . Response . Headers . Location = target ;
return Results . StatusCode ( StatusCodes . Status303SeeOther ) ;
2026-06-22 15:07:22 +01:00
}
2026-11-11 11:17:25 +00:00
// A refusal that spent nothing and needs only a correction (a taken identity, a too-early time-lock) keeps its
// challenge, whose clock must not restart. Any other failure gets a fresh challenge. The consent box always comes back unchecked.
var options = registrationOptions . Value ;
return RegistrationFormResult (
httpContext ,
challenges ,
options ,
new RegistrationPageModel ( returnPath . Value , "" , 0 , options . Captcha . FallbackWaitSeconds , Truncate ( identity ) , Truncate ( displayName ) , outcome . Message , outcome . Field ) ,
outcome . KeepChallenge & & challenges . IsLive ( challenge ) ? challenge : null ,
outcome . Status ) ;
static string Truncate ( string text ) = > text . Length < = 256 ? text : text [ . . 256 ] ;
2026-06-22 15:07:22 +01:00
} ) . AllowAnonymous ( ) ;
2026-11-11 10:15:46 +00:00
app . MapGet ( "/auth/bifrost/challenge" , ( HttpContext httpContext , RegistrationChallengeService challenges , RegistrationRateLimit rateLimit ) = >
{
if ( rateLimit . Refuse ( httpContext ) is { } refused )
{
return refused ;
}
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 ( ) ;
2026-11-11 12:11:02 +00:00
app . MapConsent ( ) ;
2026-11-11 10:15:46 +00:00
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-11-11 11:17:25 +00:00
// Renders the sign-up page with a challenge: the kept one when given, else a fresh one (null when the issuing set is full).
static IResult RegistrationFormResult (
HttpContext httpContext ,
RegistrationChallengeService challenges ,
RegistrationOptions options ,
RegistrationPageModel model ,
string? keepChallenge ,
int statusCode )
{
var token = keepChallenge ;
var difficulty = options . Captcha . Difficulty ;
if ( token is null )
{
var issued = challenges . Issue ( ) ;
if ( issued is null )
{
return Results . Text ( "Registration is busy; try again shortly." , statusCode : StatusCodes . Status503ServiceUnavailable ) ;
}
token = issued . Challenge ;
difficulty = issued . Difficulty ;
}
httpContext . Response . Headers . CacheControl = "no-store" ;
return Results . Content (
RegistrationPage . Render ( model with { Challenge = token , Difficulty = difficulty } ) ,
"text/html; charset=utf-8" ,
statusCode : statusCode ) ;
}
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 : 100 svh ; display : grid ; place - items : center ; background : # 090908 ; color : # fff8ef ; font - family : system - ui , sans - serif ; }
main { width : min ( 92 vw , 520 px ) ; padding : 28 px ; border : 1 px solid rgba ( 255 , 248 , 239 , . 16 ) ; border - radius : 8 px ; background : # 141110 ; }
h1 { margin : 0 0 10 px ; font - size : 2 rem ; line - height : 1 ; }
p { color : # c7b8ad ; }
. providers { display : grid ; gap : 10 px ; margin - top : 22 px ; }
a { display : flex ; justify - content : space - between ; align - items : center ; min - height : 46 px ; padding : 0 14 px ; border - radius : 6 px ; background : # c43e4f ; color : white ; text - decoration : none ; font - weight : 750 ; }
a . secondary { background : rgba ( 255 , 248 , 239 , . 08 ) ; border : 1 px 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 10:15:46 +00:00
string? ReturnUrl ,
string? Challenge ,
string? Nonce ,
2026-11-11 11:17:25 +00:00
int? AcceptedNoticeVersion ,
bool? Waited = null ) ;
2026-06-22 15:07:22 +01:00
public sealed record NativeBifrostRegistrationResponse (
Guid UserAccountId ,
string BifrostIdentity ,
string DisplayName ,
string MembershipStatus ,
string ReturnUrl ) ;