using Forge.Web.Components;
using Forge.Web.GitHttp;
using Forge.Web.Auth;
using Forge.Web.Services;
using Forge.Data;
using Forge.Data.Services;
using Forge.Core.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Fido2NetLib;
using Microsoft.AspNetCore.HttpOverrides;
using Forge.Core.Models;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(options => options.DetailedErrors = true);
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection(AuthOptions.SectionName));
builder.Services.AddScoped<IAuthService, ConfiguredAuthService>();
builder.Services.AddSingleton<MarkdownService>();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.AccessDeniedPath = "/login";
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization();
var workspaceRoot = ResolveWorkspaceRoot(builder.Environment.ContentRootPath);
// Configure database
var dbPath = builder.Configuration.GetValue<string>("Database:Path")
?? Path.Combine(workspaceRoot, "forge.db");
builder.Services.AddDbContext<ForgeDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}"));
// Configure git repositories root
var reposRoot = builder.Configuration.GetValue<string>("Repositories:Root")
?? Path.Combine(workspaceRoot, "repositories");
Directory.CreateDirectory(reposRoot);
// Register services
builder.Services.AddSingleton<IGitService>(sp => new GitService(reposRoot));
builder.Services.AddScoped<IRepositoryService, RepositoryService>();
// Configure Fido2
builder.Services.AddScoped<IFido2>(sp =>
{
var httpContextAccessor = sp.GetRequiredService<IHttpContextAccessor>();
var configuration = sp.GetRequiredService<IConfiguration>();
var request = httpContextAccessor.HttpContext?.Request;
var origin = ResolveWebAuthnOrigin(request, configuration["BaseUrl"]);
var serverDomain = ResolveWebAuthnServerDomain(request, configuration["BaseUrl"]);
var fido2Config = new Fido2Configuration
{
ServerDomain = serverDomain,
ServerName = "Forge",
Origins = new HashSet<string> { origin },
TimestampDriftTolerance = 300000
};
return new Fido2NetLib.Fido2(fido2Config);
});
builder.Services.AddScoped<IFido2Service, Fido2Service>();
// Git HTTP middleware
builder.Services.AddScoped<GitHttpMiddleware>(sp =>
new GitHttpMiddleware(
sp.GetRequiredService<IRepositoryService>(),
sp.GetRequiredService<IGitService>(),
sp.GetRequiredService<IAuthService>(),
reposRoot
)
);
var app = builder.Build();
// Ensure database is created and validate repositories
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ForgeDbContext>();
db.Database.EnsureCreated();
// Ensure PasskeyCredentials table exists (for existing databases)
var connection = db.Database.GetDbConnection();
await connection.OpenAsync();
using var cmd = connection.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS PasskeyCredentials (
Id TEXT PRIMARY KEY,
Username TEXT NOT NULL,
CredentialId BLOB NOT NULL UNIQUE,
PublicKey BLOB NOT NULL,
SignCount INTEGER NOT NULL,
Name TEXT,
CreatedAt TEXT NOT NULL,
LastUsedAt TEXT
);
CREATE INDEX IF NOT EXISTS IX_PasskeyCredentials_Username ON PasskeyCredentials (Username);
CREATE INDEX IF NOT EXISTS IX_PasskeyCredentials_CredentialId ON PasskeyCredentials (CredentialId);
CREATE TABLE IF NOT EXISTS UserAccounts (
Id TEXT PRIMARY KEY,
Username TEXT NOT NULL,
PasswordHash TEXT NOT NULL,
IsAdmin INTEGER NOT NULL DEFAULT 0,
Role TEXT NOT NULL DEFAULT 'User',
CreatedAt TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS IX_UserAccounts_Username ON UserAccounts (Username);
""";
await cmd.ExecuteNonQueryAsync();
try
{
cmd.CommandText = "ALTER TABLE UserAccounts ADD COLUMN Role TEXT NOT NULL DEFAULT 'User';";
await cmd.ExecuteNonQueryAsync();
}
catch
{
// Column already exists on newer databases.
}
cmd.CommandText = """
UPDATE UserAccounts
SET Role = CASE WHEN IsAdmin = 1 THEN 'Admin' ELSE 'User' END
WHERE Role IS NULL OR Role = '';
""";
await cmd.ExecuteNonQueryAsync();
var authService = scope.ServiceProvider.GetRequiredService<IAuthService>();
await authService.EnsureConfiguredAdminAsync();
// Validate all repos in DB exist on disk, repair any missing
var gitService = scope.ServiceProvider.GetRequiredService<IGitService>();
var repoService = scope.ServiceProvider.GetRequiredService<IRepositoryService>();
var imported = await repoService.SyncFromDiskAsync(reposRoot);
if (imported > 0)
{
Console.WriteLine($"[Forge] Imported {imported} repositories from disk");
}
var repos = await repoService.GetAllAsync();
var repaired = await gitService.ValidateAndRepairRepositoriesAsync(repos);
if (repaired > 0)
{
Console.WriteLine($"[Forge] Repaired {repaired} missing repositories");
}
}
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapPost("/auth/login", async (HttpContext context, [FromServices] IAuthService authService) =>
{
var form = await context.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
var returnUrl = form["returnUrl"].ToString();
var user = await authService.ValidateCredentialsAsync(username, password);
if (user is null)
{
var target = string.IsNullOrWhiteSpace(returnUrl) ? "/login?error=true" : $"/login?error=true&returnUrl={Uri.EscapeDataString(returnUrl)}";
return Results.LocalRedirect(target);
}
var claims = new List<Claim>
{
new(ClaimTypes.Name, user.Username)
};
if (!string.IsNullOrWhiteSpace(user.Role))
{
claims.Add(new(ClaimTypes.Role, user.Role));
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
if (!string.IsNullOrWhiteSpace(returnUrl) && Uri.IsWellFormedUriString(returnUrl, UriKind.Relative) && returnUrl.StartsWith('/'))
{
return Results.LocalRedirect(returnUrl);
}
return Results.LocalRedirect(GetDefaultPostLoginPath(user.Username, user.Role));
});
app.MapPost("/auth/logout", async (HttpContext context) =>
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.LocalRedirect("/");
});
// WebAuthn / Passkey endpoints
app.MapPost("/auth/passkey/register/start", async (
HttpContext context,
[FromServices] IFido2Service fido2Service) =>
{
var username = context.User.Identity?.Name;
if (string.IsNullOrEmpty(username))
{
return Results.Unauthorized();
}
var options = await fido2Service.StartRegistrationAsync(username);
// Return options as JSON (Fido2NetLib handles serialization)
return Results.Json(options);
});
app.MapPost("/auth/passkey/register/complete", async (
HttpContext context,
[FromServices] IFido2Service fido2Service) =>
{
var username = context.User.Identity?.Name;
if (string.IsNullOrEmpty(username))
{
return Results.Unauthorized();
}
try
{
// Parse JSON manually to avoid base64url conversion issues
using var reader = new StreamReader(context.Request.Body);
var json = await reader.ReadToEndAsync();
var node = System.Text.Json.Nodes.JsonNode.Parse(json);
var response = new Fido2NetLib.AuthenticatorAttestationRawResponse
{
Id = node!["id"]!.GetValue<string>(),
RawId = Base64Url.Decode(node["rawId"]!.GetValue<string>()),
Type = Fido2NetLib.Objects.PublicKeyCredentialType.PublicKey,
Response = new Fido2NetLib.AuthenticatorAttestationRawResponse.AttestationResponse
{
ClientDataJson = Base64Url.Decode(node["response"]!["clientDataJSON"]!.GetValue<string>()),
AttestationObject = Base64Url.Decode(node["response"]!["attestationObject"]!.GetValue<string>()),
Transports = []
},
ClientExtensionResults = new Fido2NetLib.Objects.AuthenticationExtensionsClientOutputs()
};
var deviceName = node["deviceName"]?.GetValue<string>();
var credential = await fido2Service.CompleteRegistrationAsync(username, response, deviceName);
return Results.Json(new { success = true, credentialId = credential.Id });
}
catch (Exception ex)
{
return Results.BadRequest(new { error = ex.Message });
}
});
app.MapGet("/auth/passkey/authenticate/start", async (
[FromServices] IFido2Service fido2Service) =>
{
var options = await fido2Service.StartAuthenticationAsync();
return Results.Json(options);
});
app.MapPost("/auth/passkey/authenticate/complete", async (
HttpContext context,
[FromServices] IFido2Service fido2Service,
[FromServices] IAuthService authService) =>
{
try
{
// Parse JSON manually to avoid base64url conversion issues
using var reader = new StreamReader(context.Request.Body);
var json = await reader.ReadToEndAsync();
var node = System.Text.Json.Nodes.JsonNode.Parse(json);
var response = new Fido2NetLib.AuthenticatorAssertionRawResponse
{
Id = node!["id"]!.GetValue<string>(),
RawId = Base64Url.Decode(node["rawId"]!.GetValue<string>()),
Type = Fido2NetLib.Objects.PublicKeyCredentialType.PublicKey,
Response = new Fido2NetLib.AuthenticatorAssertionRawResponse.AssertionResponse
{
ClientDataJson = Base64Url.Decode(node["response"]!["clientDataJSON"]!.GetValue<string>()),
AuthenticatorData = Base64Url.Decode(node["response"]!["authenticatorData"]!.GetValue<string>()),
Signature = Base64Url.Decode(node["response"]!["signature"]!.GetValue<string>()),
UserHandle = node["response"]?["userHandle"]?.GetValue<string>() is string uh ? Base64Url.Decode(uh) : null
},
ClientExtensionResults = new Fido2NetLib.Objects.AuthenticationExtensionsClientOutputs()
};
var username = await fido2Service.CompleteAuthenticationAsync(response);
if (username == null)
{
return Results.Json(new { success = false, error = "Authentication failed" });
}
var user = await authService.GetUserAsync(username);
if (user is null)
{
return Results.Json(new { success = false, error = "User no longer exists" });
}
var requestedReturnUrl = node["returnUrl"]?.GetValue<string>();
// Sign the user in
var claims = new List<Claim>
{
new(ClaimTypes.Name, user.Username)
};
if (!string.IsNullOrWhiteSpace(user.Role))
{
claims.Add(new(ClaimTypes.Role, user.Role));
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
var redirect = !string.IsNullOrWhiteSpace(requestedReturnUrl)
&& Uri.IsWellFormedUriString(requestedReturnUrl, UriKind.Relative)
&& requestedReturnUrl.StartsWith('/')
? requestedReturnUrl
: GetDefaultPostLoginPath(user.Username, user.Role);
return Results.Json(new { success = true, redirect });
}
catch (Exception ex)
{
return Results.BadRequest(new { error = ex.Message });
}
});
app.MapGet("/auth/passkey/credentials", async (
HttpContext context,
[FromServices] IFido2Service fido2Service,
[FromServices] IAuthService authService) =>
{
var username = context.User.Identity?.Name;
if (string.IsNullOrEmpty(username))
{
return Results.Unauthorized();
}
var credentials = await fido2Service.GetCredentialsAsync(username);
return Results.Json(credentials.Select(c => new {
c.Id,
c.Name,
c.CreatedAt,
c.LastUsedAt
}));
});
app.MapDelete("/auth/passkey/credentials/{id}", async (
HttpContext context,
Guid id,
[FromServices] IFido2Service fido2Service,
[FromServices] IAuthService authService) =>
{
var username = context.User.Identity?.Name;
if (string.IsNullOrEmpty(username))
{
return Results.Unauthorized();
}
await fido2Service.DeleteCredentialAsync(id, username);
return Results.Json(new { success = true });
});
// Git Smart HTTP endpoints
app.MapMethods("/{owner}/{repo}.git/{**rest}", new[] { "GET", "POST" }, async (HttpContext context, string owner, string repo,
[FromServices] GitHttpMiddleware git) =>
{
await git.HandleAsync(context, owner, repo);
});
app.Run();
static string ResolveWebAuthnOrigin(HttpRequest? request, string? configuredBaseUrl)
{
if (request is not null && request.Host.HasValue)
{
return $"{request.Scheme}://{request.Host.Value}".TrimEnd('/');
}
return (configuredBaseUrl ?? "http://localhost:5128").TrimEnd('/');
}
static string ResolveWebAuthnServerDomain(HttpRequest? request, string? configuredBaseUrl)
{
if (request is not null && request.Host.HasValue)
{
return request.Host.Host;
}
return new Uri(configuredBaseUrl ?? "http://localhost:5128").Host;
}
static string ResolveWorkspaceRoot(string contentRootPath)
{
var current = new DirectoryInfo(contentRootPath);
while (current is not null)
{
var gitDir = Path.Combine(current.FullName, ".git");
var flakeFile = Path.Combine(current.FullName, "flake.nix");
if (Directory.Exists(gitDir) || File.Exists(flakeFile))
{
return current.FullName;
}
current = current.Parent;
}
return contentRootPath;
}
static string GetDefaultPostLoginPath(string username, string role) =>
string.Equals(role, UserRoles.Admin, StringComparison.Ordinal) ? "/admin" : $"/{Uri.EscapeDataString(username)}";
file static class Base64Url
{
public static byte[] Decode(string s) =>
Convert.FromBase64String(s.Replace('-', '+').Replace('_', '/').PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
}