From ed95535e0a6192e74ff34db6c6a17578a9056559 Mon Sep 17 00:00:00 2001 From: 6d486f49 <76097bcc@gmail.com> Date: Fri, 3 Jul 2026 14:31:40 -0400 Subject: [PATCH] Vibe passkey --- Chrono/.claude/settings.local.json | 10 +- Chrono/Server/AppDbContext.cs | 1 + Chrono/Server/Components/App.razor | 5 +- Chrono/Server/Components/Pages/Login.razor | 110 +++++++++ .../Server/Components/Pages/Login.razor.css | 29 +++ .../Server/Components/RedirectToLogin.razor | 8 + Chrono/Server/Components/Routes.razor | 6 +- Chrono/Server/Components/_Imports.razor | 2 + Chrono/Server/Controllers/AuthController.cs | 219 ++++++++++++++++++ Chrono/Server/Controllers/DecksController.cs | 2 + Chrono/Server/Controllers/NotesController.cs | 2 + ...03120000_AddPasskeyCredentials.Designer.cs | 110 +++++++++ .../20260703120000_AddPasskeyCredentials.cs | 41 ++++ .../Migrations/AppDbContextModelSnapshot.cs | 34 +++ Chrono/Server/Models/PasskeyCredential.cs | 12 + Chrono/Server/Program.cs | 37 ++- Chrono/Server/Server.csproj | 2 + Chrono/Server/appsettings.json | 7 +- Chrono/Server/wwwroot/js/passkey.js | 99 ++++++++ Chrono/Tests/AuthenticatedPageTest.cs | 20 ++ Chrono/Tests/DeckBuilderTests.cs | 4 +- Chrono/Tests/FeatureTests.cs | 5 +- Chrono/Tests/PageObjects/LoginPage.cs | 21 ++ Chrono/Tests/PasskeyTests.cs | 142 ++++++++++++ Chrono/Tests/PlaywrightTests.cs | 4 +- 25 files changed, 917 insertions(+), 15 deletions(-) create mode 100644 Chrono/Server/Components/Pages/Login.razor create mode 100644 Chrono/Server/Components/Pages/Login.razor.css create mode 100644 Chrono/Server/Components/RedirectToLogin.razor create mode 100644 Chrono/Server/Controllers/AuthController.cs create mode 100644 Chrono/Server/Migrations/20260703120000_AddPasskeyCredentials.Designer.cs create mode 100644 Chrono/Server/Migrations/20260703120000_AddPasskeyCredentials.cs create mode 100644 Chrono/Server/Models/PasskeyCredential.cs create mode 100644 Chrono/Server/wwwroot/js/passkey.js create mode 100644 Chrono/Tests/AuthenticatedPageTest.cs create mode 100644 Chrono/Tests/PageObjects/LoginPage.cs create mode 100644 Chrono/Tests/PasskeyTests.cs diff --git a/Chrono/.claude/settings.local.json b/Chrono/.claude/settings.local.json index 9601281..663232f 100644 --- a/Chrono/.claude/settings.local.json +++ b/Chrono/.claude/settings.local.json @@ -8,7 +8,15 @@ "Bash(awk '{print $NF}')", "Bash(dotnet test *)", "Bash(dotnet user-secrets *)", - "Bash(env)" + "Bash(env)", + "Bash(grep -E \"\\\\.\\(cs|json|razor\\)$\")", + "Bash(dotnet package *)", + "Bash(dotnet restore *)", + "Bash(powershell -Command '[Reflection.Assembly]::LoadFrom\\('\\\\''C:\\\\Users\\\\jonmc\\\\.nuget\\\\packages\\\\microsoft.playwright\\\\1.60.0\\\\lib\\\\netstandard2.0\\\\Microsoft.Playwright.dll'\\\\''\\) | Out-Null; [Microsoft.Playwright.IBrowserContext].GetMethods\\(\\) | Where-Object { $_.Name -match '\\\\''Virtual|Authenticator|CDP|Request'\\\\'' } | Select-Object Name')", + "Bash(powershell -Command 'Add-Type -Path '\\\\''C:\\\\Users\\\\jonmc\\\\.nuget\\\\packages\\\\microsoft.playwright\\\\1.60.0\\\\lib\\\\netstandard2.0\\\\Microsoft.Playwright.dll'\\\\''; [AppDomain]::CurrentDomain.GetAssemblies\\(\\) | Where-Object { $_.GetName\\(\\).Name -eq '\\\\''Microsoft.Playwright'\\\\'' } | ForEach-Object { $_.GetExportedTypes\\(\\) | Where-Object { $_.Name -match '\\\\''Virtual|Authenticator|IBrowserContext'\\\\'' } | Select-Object FullName }')", + "Bash(python3 -c \"import json; d=json.load\\(open\\('/c/Users/jonmc/.nuget/packages/microsoft.playwright/1.60.0/.playwright/package/api.json'\\)\\); keys=[k for k in str\\(d\\) if 'Virtual' in k or 'Authenticator' in k]; print\\(keys[:5]\\)\")", + "Bash(python3 -c ' *)", + "Bash(powershell -Command ' *)" ] } } diff --git a/Chrono/Server/AppDbContext.cs b/Chrono/Server/AppDbContext.cs index c789a72..56f0387 100644 --- a/Chrono/Server/AppDbContext.cs +++ b/Chrono/Server/AppDbContext.cs @@ -12,6 +12,7 @@ public class AppDbContext : DbContext public DbSet CardNotes { get; set; } public DbSet UserDecks { get; set; } + public DbSet PasskeyCredentials { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/Chrono/Server/Components/App.razor b/Chrono/Server/Components/App.razor index 56b92ba..d0350bd 100644 --- a/Chrono/Server/Components/App.razor +++ b/Chrono/Server/Components/App.razor @@ -15,12 +15,15 @@ + - + + + diff --git a/Chrono/Server/Components/Pages/Login.razor b/Chrono/Server/Components/Pages/Login.razor new file mode 100644 index 0000000..c4e6815 --- /dev/null +++ b/Chrono/Server/Components/Pages/Login.razor @@ -0,0 +1,110 @@ +@page "/login" +@rendermode InteractiveServer +@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous] +@using Microsoft.AspNetCore.Components.Authorization +@inject IJSRuntime JS +@inject NavigationManager Nav +@inject AuthenticationStateProvider AuthStateProvider + +Sign In — Chrono CCG + + + +@code { + private bool _enrolled; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + if (authState.User.Identity?.IsAuthenticated == true) + { + Nav.NavigateTo("/", replace: true); + return; + } + + try + { + using var http = new HttpClient { BaseAddress = new Uri(Nav.BaseUri) }; + var status = await http.GetFromJsonAsync("/api/auth/status"); + _enrolled = status?.Enrolled ?? false; + } + catch + { + _enrolled = false; + } + } + + private async Task SignIn() + { + _busy = true; + _error = null; + try + { + await JS.InvokeVoidAsync("passkeyLogin"); + Nav.NavigateTo("/", forceLoad: true); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private async Task Enroll() + { + _busy = true; + _error = null; + try + { + await JS.InvokeVoidAsync("passkeyEnroll"); + Nav.NavigateTo("/", forceLoad: true); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private record StatusResponse(bool Enrolled, bool Authenticated); +} diff --git a/Chrono/Server/Components/Pages/Login.razor.css b/Chrono/Server/Components/Pages/Login.razor.css new file mode 100644 index 0000000..45f76e1 --- /dev/null +++ b/Chrono/Server/Components/Pages/Login.razor.css @@ -0,0 +1,29 @@ +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background: var(--bs-body-bg, #f8f9fa); +} + +.login-card { + background: #fff; + border-radius: 12px; + box-shadow: 0 4px 24px rgba(0,0,0,0.10); + padding: 2.5rem 2rem; + width: 100%; + max-width: 380px; + text-align: center; +} + +.login-title { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 1.5rem; +} + +.login-hint { + color: #6c757d; + margin-bottom: 1.25rem; + font-size: 0.95rem; +} diff --git a/Chrono/Server/Components/RedirectToLogin.razor b/Chrono/Server/Components/RedirectToLogin.razor new file mode 100644 index 0000000..c11cdcc --- /dev/null +++ b/Chrono/Server/Components/RedirectToLogin.razor @@ -0,0 +1,8 @@ +@inject NavigationManager Nav + +@code { + protected override void OnInitialized() + { + Nav.NavigateTo("/login", forceLoad: true); + } +} diff --git a/Chrono/Server/Components/Routes.razor b/Chrono/Server/Components/Routes.razor index 7081085..a76cffd 100644 --- a/Chrono/Server/Components/Routes.razor +++ b/Chrono/Server/Components/Routes.razor @@ -1,6 +1,10 @@ - + + + + + diff --git a/Chrono/Server/Components/_Imports.razor b/Chrono/Server/Components/_Imports.razor index accb9fb..4cd1176 100644 --- a/Chrono/Server/Components/_Imports.razor +++ b/Chrono/Server/Components/_Imports.razor @@ -1,5 +1,7 @@ @using System.Net.Http @using System.Net.Http.Json +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization @using Chrono.Model @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing diff --git a/Chrono/Server/Controllers/AuthController.cs b/Chrono/Server/Controllers/AuthController.cs new file mode 100644 index 0000000..af0a5ab --- /dev/null +++ b/Chrono/Server/Controllers/AuthController.cs @@ -0,0 +1,219 @@ +using System.Security.Claims; +using Fido2NetLib; +using Fido2NetLib.Objects; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Server.Models; + +namespace Server.Controllers; + +[AllowAnonymous] +[ApiController] +[Route("api/auth")] +public class AuthController : ControllerBase +{ + private const string RegOptionsKey = "fido2.reg.options"; + private const string AssertOptionsKey = "fido2.assert.options"; + + private readonly IFido2 _fido2; + private readonly AppDbContext _db; + + public AuthController(IFido2 fido2, AppDbContext db) + { + _fido2 = fido2; + _db = db; + } + + // ── Registration ────────────────────────────────────────────────────────── + + [HttpPost("register/options")] + public async Task RegisterOptions() + { + // Only allow registration when unenrolled or already authenticated + var hasCredentials = await _db.PasskeyCredentials.AnyAsync(); + if (hasCredentials && !User.Identity!.IsAuthenticated) + return Forbid(); + + var user = new Fido2User + { + Id = Guid.NewGuid().ToByteArray(), + Name = "owner", + DisplayName = "Owner" + }; + + var existingKeys = await _db.PasskeyCredentials + .Select(c => new PublicKeyCredentialDescriptor(c.CredentialId)) + .ToListAsync(); + + var options = _fido2.RequestNewCredential(new RequestNewCredentialParams + { + User = user, + ExcludeCredentials = existingKeys, + AuthenticatorSelection = new AuthenticatorSelection + { + ResidentKey = ResidentKeyRequirement.Required, + UserVerification = UserVerificationRequirement.Required, + }, + AttestationPreference = AttestationConveyancePreference.None, + }); + + HttpContext.Session.SetString(RegOptionsKey, options.ToJson()); + return Ok(options); + } + + [HttpPost("register/complete")] + public async Task RegisterComplete([FromBody] AuthenticatorAttestationRawResponse attestationResponse) + { + var json = HttpContext.Session.GetString(RegOptionsKey); + if (json is null) return BadRequest("Session expired. Please retry enrollment."); + + var options = CredentialCreateOptions.FromJson(json); + HttpContext.Session.Remove(RegOptionsKey); + + var hasCredentials = await _db.PasskeyCredentials.AnyAsync(); + if (hasCredentials && !User.Identity!.IsAuthenticated) + return Forbid(); + + IsCredentialIdUniqueToUserAsyncDelegate isUnique = async (args, _) => + !await _db.PasskeyCredentials.AnyAsync(c => c.CredentialId == args.CredentialId); + + var result = await _fido2.MakeNewCredentialAsync(new MakeNewCredentialParams + { + AttestationResponse = attestationResponse, + OriginalOptions = options, + IsCredentialIdUniqueToUserCallback = isUnique, + }); + + var credential = new PasskeyCredential + { + CredentialId = result.Id, + PublicKey = result.PublicKey, + SignCount = result.SignCount, + UserHandle = result.User.Id, + AaGuid = result.AaGuid.ToString(), + }; + _db.PasskeyCredentials.Add(credential); + await _db.SaveChangesAsync(); + + var claims = new[] { new Claim(ClaimTypes.Name, "owner") }; + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity), + new AuthenticationProperties { IsPersistent = true }); + + return Ok(new { message = "Passkey enrolled and authenticated." }); + } + + // ── Authentication ──────────────────────────────────────────────────────── + + [HttpPost("login/options")] + public async Task LoginOptions() + { + var existingKeys = await _db.PasskeyCredentials + .Select(c => new PublicKeyCredentialDescriptor(c.CredentialId)) + .ToListAsync(); + + var options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams + { + AllowedCredentials = existingKeys, + UserVerification = UserVerificationRequirement.Required, + }); + + HttpContext.Session.SetString(AssertOptionsKey, options.ToJson()); + return Ok(options); + } + + [HttpPost("login/complete")] + public async Task LoginComplete([FromBody] AuthenticatorAssertionRawResponse assertionResponse) + { + var json = HttpContext.Session.GetString(AssertOptionsKey); + if (json is null) return BadRequest("Session expired. Please retry sign-in."); + + var options = AssertionOptions.FromJson(json); + HttpContext.Session.Remove(AssertOptionsKey); + + // assertionResponse.RawId contains the credential ID as raw bytes + var credentialId = assertionResponse.RawId; + var allCredentials = await _db.PasskeyCredentials.ToListAsync(); + var stored = allCredentials.FirstOrDefault(c => c.CredentialId.SequenceEqual(credentialId)); + + if (stored is null) return Unauthorized("Credential not recognized."); + + IsUserHandleOwnerOfCredentialIdAsync isOwner = (args, _) => + Task.FromResult(args.UserHandle.SequenceEqual(stored.UserHandle)); + + var result = await _fido2.MakeAssertionAsync(new MakeAssertionParams + { + AssertionResponse = assertionResponse, + OriginalOptions = options, + StoredPublicKey = stored.PublicKey, + StoredSignatureCounter = (uint)stored.SignCount, + IsUserHandleOwnerOfCredentialIdCallback = isOwner, + }); + + stored.SignCount = result.SignCount; + await _db.SaveChangesAsync(); + + var claims = new[] { new Claim(ClaimTypes.Name, "owner") }; + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity), + new AuthenticationProperties { IsPersistent = true }); + + return Ok(new { message = "Authenticated." }); + } + + // ── Logout ──────────────────────────────────────────────────────────────── + + [HttpPost("logout")] + public async Task Logout() + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Ok(); + } + + // ── Status ──────────────────────────────────────────────────────────────── + + [HttpGet("status")] + public async Task Status() + { + var hasCredentials = await _db.PasskeyCredentials.AnyAsync(); + return Ok(new + { + enrolled = hasCredentials, + authenticated = User.Identity?.IsAuthenticated ?? false, + }); + } + + // ── Dev-only test helpers (Development environment only) ────────────────── + + [HttpPost("dev-login")] + public async Task DevLogin([FromServices] IWebHostEnvironment env) + { + if (!env.IsDevelopment()) return NotFound(); + + var claims = new[] { new Claim(ClaimTypes.Name, "owner") }; + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity), + new AuthenticationProperties { IsPersistent = false }); + + return Ok(new { message = "Dev login successful." }); + } + + [HttpDelete("credentials")] + public async Task DeleteAllCredentials([FromServices] IWebHostEnvironment env) + { + if (!env.IsDevelopment()) return NotFound(); + + _db.PasskeyCredentials.RemoveRange(_db.PasskeyCredentials); + await _db.SaveChangesAsync(); + return Ok(new { message = "All credentials deleted." }); + } +} diff --git a/Chrono/Server/Controllers/DecksController.cs b/Chrono/Server/Controllers/DecksController.cs index faba649..43fe98e 100644 --- a/Chrono/Server/Controllers/DecksController.cs +++ b/Chrono/Server/Controllers/DecksController.cs @@ -1,9 +1,11 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Server.Models; namespace Server.Controllers; +[Authorize] [ApiController] [Route("api/[controller]")] public class DecksController : ControllerBase diff --git a/Chrono/Server/Controllers/NotesController.cs b/Chrono/Server/Controllers/NotesController.cs index 39009c3..1fdedf9 100644 --- a/Chrono/Server/Controllers/NotesController.cs +++ b/Chrono/Server/Controllers/NotesController.cs @@ -1,8 +1,10 @@ using Chrono.Model; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Server.Controllers; +[Authorize] [ApiController] [Route("api/[controller]")] public class NotesController : ControllerBase diff --git a/Chrono/Server/Migrations/20260703120000_AddPasskeyCredentials.Designer.cs b/Chrono/Server/Migrations/20260703120000_AddPasskeyCredentials.Designer.cs new file mode 100644 index 0000000..d8ca579 --- /dev/null +++ b/Chrono/Server/Migrations/20260703120000_AddPasskeyCredentials.Designer.cs @@ -0,0 +1,110 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Server; + +#nullable disable + +namespace Server.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260703120000_AddPasskeyCredentials")] + partial class AddPasskeyCredentials + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Chrono.Model.CardNote", b => + { + b.Property("CardName") + .HasColumnType("text"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("CardName"); + + b.ToTable("CardNotes"); + }); + + modelBuilder.Entity("Server.Models.PasskeyCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AaGuid") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignCount") + .HasColumnType("bigint"); + + b.Property("UserHandle") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.ToTable("PasskeyCredentials"); + }); + + modelBuilder.Entity("Server.Models.UserDeck", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("Cards") + .IsRequired() + .HasColumnType("jsonb"); + + b.PrimitiveCollection("Divers") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Season") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("UserDecks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Chrono/Server/Migrations/20260703120000_AddPasskeyCredentials.cs b/Chrono/Server/Migrations/20260703120000_AddPasskeyCredentials.cs new file mode 100644 index 0000000..633947b --- /dev/null +++ b/Chrono/Server/Migrations/20260703120000_AddPasskeyCredentials.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Server.Migrations +{ + /// + public partial class AddPasskeyCredentials : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PasskeyCredentials", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CredentialId = table.Column(type: "bytea", nullable: false), + PublicKey = table.Column(type: "bytea", nullable: false), + SignCount = table.Column(type: "bigint", nullable: false), + UserHandle = table.Column(type: "bytea", nullable: false), + AaGuid = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PasskeyCredentials", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PasskeyCredentials"); + } + } +} diff --git a/Chrono/Server/Migrations/AppDbContextModelSnapshot.cs b/Chrono/Server/Migrations/AppDbContextModelSnapshot.cs index 982a629..9fd0ea4 100644 --- a/Chrono/Server/Migrations/AppDbContextModelSnapshot.cs +++ b/Chrono/Server/Migrations/AppDbContextModelSnapshot.cs @@ -36,6 +36,40 @@ namespace Server.Migrations b.ToTable("CardNotes"); }); + modelBuilder.Entity("Server.Models.PasskeyCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AaGuid") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SignCount") + .HasColumnType("bigint"); + + b.Property("UserHandle") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.ToTable("PasskeyCredentials"); + }); + modelBuilder.Entity("Server.Models.UserDeck", b => { b.Property("Id") diff --git a/Chrono/Server/Models/PasskeyCredential.cs b/Chrono/Server/Models/PasskeyCredential.cs new file mode 100644 index 0000000..aa788c8 --- /dev/null +++ b/Chrono/Server/Models/PasskeyCredential.cs @@ -0,0 +1,12 @@ +namespace Server.Models; + +public class PasskeyCredential +{ + public int Id { get; set; } + public required byte[] CredentialId { get; set; } + public required byte[] PublicKey { get; set; } + public long SignCount { get; set; } + public required byte[] UserHandle { get; set; } + public string AaGuid { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/Chrono/Server/Program.cs b/Chrono/Server/Program.cs index 744880d..5b37d21 100644 --- a/Chrono/Server/Program.cs +++ b/Chrono/Server/Program.cs @@ -1,3 +1,5 @@ +using Fido2NetLib; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.EntityFrameworkCore; using Server; using Server.Components; @@ -14,6 +16,35 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddHttpClient(); +builder.Services.AddDistributedMemoryCache(); +builder.Services.AddSession(options => +{ + options.IdleTimeout = TimeSpan.FromMinutes(5); + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; +}); + +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/login"; + options.LogoutPath = "/logout"; + options.ExpireTimeSpan = TimeSpan.FromDays(30); + options.SlidingExpiration = true; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.Cookie.SameSite = SameSiteMode.Strict; + }); +builder.Services.AddAuthorization(); + +builder.Services.AddFido2(options => +{ + options.ServerDomain = builder.Configuration["Fido2:ServerDomain"]!; + options.ServerName = builder.Configuration["Fido2:ServerName"]!; + options.Origins = builder.Configuration.GetSection("Fido2:Origins").Get>()!; + options.TimestampDriftTolerance = 300_000; +}); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); builder.Services.AddDbContext(options => @@ -23,7 +54,6 @@ builder.Services.AddDbContext(options => var app = builder.Build(); -// Apply migrations using (var scope = app.Services.CreateScope()) { try @@ -41,6 +71,9 @@ using (var scope = app.Services.CreateScope()) if (!app.Environment.IsDevelopment()) app.UseExceptionHandler("/Error"); app.UseStaticFiles(); +app.UseSession(); +app.UseAuthentication(); +app.UseAuthorization(); app.UseAntiforgery(); app.MapControllers(); @@ -48,4 +81,4 @@ app.MapRazorComponents() .AddInteractiveServerRenderMode() .AddAdditionalAssemblies(typeof(Home).Assembly); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/Chrono/Server/Server.csproj b/Chrono/Server/Server.csproj index a41916f..a9f65f0 100644 --- a/Chrono/Server/Server.csproj +++ b/Chrono/Server/Server.csproj @@ -6,6 +6,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Chrono/Server/appsettings.json b/Chrono/Server/appsettings.json index 10f68b8..b2f43dc 100644 --- a/Chrono/Server/appsettings.json +++ b/Chrono/Server/appsettings.json @@ -5,5 +5,10 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Fido2": { + "ServerDomain": "localhost", + "ServerName": "Chrono CCG", + "Origins": [ "https://localhost:7001", "http://localhost:5000" ] + } } diff --git a/Chrono/Server/wwwroot/js/passkey.js b/Chrono/Server/wwwroot/js/passkey.js new file mode 100644 index 0000000..36cef5f --- /dev/null +++ b/Chrono/Server/wwwroot/js/passkey.js @@ -0,0 +1,99 @@ +// WebAuthn helpers — base64url encoding compatible with Fido2NetLib + +function base64urlToBuffer(base64url) { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64); + return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer; +} + +function bufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function prepareCreationOptions(options) { + options.challenge = base64urlToBuffer(options.challenge); + options.user.id = base64urlToBuffer(options.user.id); + if (options.excludeCredentials) { + options.excludeCredentials = options.excludeCredentials.map(c => ({ + ...c, + id: base64urlToBuffer(c.id), + })); + } + return options; +} + +function prepareRequestOptions(options) { + options.challenge = base64urlToBuffer(options.challenge); + if (options.allowCredentials) { + options.allowCredentials = options.allowCredentials.map(c => ({ + ...c, + id: base64urlToBuffer(c.id), + })); + } + return options; +} + +function serializeAttestation(credential) { + return { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + response: { + attestationObject: bufferToBase64url(credential.response.attestationObject), + clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), + }, + extensions: credential.getClientExtensionResults(), + }; +} + +function serializeAssertion(credential) { + return { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + response: { + authenticatorData: bufferToBase64url(credential.response.authenticatorData), + clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), + signature: bufferToBase64url(credential.response.signature), + userHandle: credential.response.userHandle + ? bufferToBase64url(credential.response.userHandle) + : null, + }, + extensions: credential.getClientExtensionResults(), + }; +} + +window.passkeyEnroll = async function () { + const optRes = await fetch('/api/auth/register/options', { method: 'POST' }); + if (!optRes.ok) throw new Error(await optRes.text()); + const options = prepareCreationOptions(await optRes.json()); + + const credential = await navigator.credentials.create({ publicKey: options }); + + const completeRes = await fetch('/api/auth/register/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(serializeAttestation(credential)), + }); + if (!completeRes.ok) throw new Error(await completeRes.text()); + return await completeRes.json(); +}; + +window.passkeyLogin = async function () { + const optRes = await fetch('/api/auth/login/options', { method: 'POST' }); + if (!optRes.ok) throw new Error(await optRes.text()); + const options = prepareRequestOptions(await optRes.json()); + + const credential = await navigator.credentials.get({ publicKey: options }); + + const completeRes = await fetch('/api/auth/login/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(serializeAssertion(credential)), + }); + if (!completeRes.ok) throw new Error(await completeRes.text()); + return await completeRes.json(); +}; diff --git a/Chrono/Tests/AuthenticatedPageTest.cs b/Chrono/Tests/AuthenticatedPageTest.cs new file mode 100644 index 0000000..37d9bcb --- /dev/null +++ b/Chrono/Tests/AuthenticatedPageTest.cs @@ -0,0 +1,20 @@ +using Microsoft.Playwright.NUnit; + +namespace Tests; + +/// +/// Base class for Playwright tests that require an authenticated session. +/// Signs in via the dev-only bypass endpoint before each test. +/// +public abstract class AuthenticatedPageTest : PageTest +{ + protected const string BaseUrl = "http://localhost:5256"; + + [SetUp] + public async Task SignIn() + { + var response = await Page.APIRequestContext.PostAsync($"{BaseUrl}/api/auth/dev-login"); + if (!response.Ok) + throw new Exception($"Dev login failed with status {response.Status}. Ensure the server is running in Development mode."); + } +} diff --git a/Chrono/Tests/DeckBuilderTests.cs b/Chrono/Tests/DeckBuilderTests.cs index 6b81261..c80dd4a 100644 --- a/Chrono/Tests/DeckBuilderTests.cs +++ b/Chrono/Tests/DeckBuilderTests.cs @@ -1,16 +1,14 @@ using System.Text.RegularExpressions; using Microsoft.Playwright; -using Microsoft.Playwright.NUnit; using Tests.PageObjects; namespace Tests; [Parallelizable(ParallelScope.Self)] [TestFixture] -public class DeckBuilderTests : PageTest +public class DeckBuilderTests : AuthenticatedPageTest { private DeckBuilderPage _builderPage = null!; - private const string BaseUrl = "http://localhost:5256"; private Guid _savedDeckId = Guid.Empty; [SetUp] diff --git a/Chrono/Tests/FeatureTests.cs b/Chrono/Tests/FeatureTests.cs index 2587340..c99aae0 100644 --- a/Chrono/Tests/FeatureTests.cs +++ b/Chrono/Tests/FeatureTests.cs @@ -1,11 +1,10 @@ -using Microsoft.Playwright.NUnit; -using Tests.PageObjects; +using Tests.PageObjects; namespace Tests; [Parallelizable(ParallelScope.Self)] [TestFixture] -public class FeatureTests : PageTest +public class FeatureTests : AuthenticatedPageTest { [SetUp] public void Setup() diff --git a/Chrono/Tests/PageObjects/LoginPage.cs b/Chrono/Tests/PageObjects/LoginPage.cs new file mode 100644 index 0000000..001cb88 --- /dev/null +++ b/Chrono/Tests/PageObjects/LoginPage.cs @@ -0,0 +1,21 @@ +using Microsoft.Playwright; + +namespace Tests.PageObjects; + +public class LoginPage : BasePage +{ + public LoginPage(IPage page) : base(page) { } + + public ILocator EnrollButton => Page.GetByRole(AriaRole.Button, new() { Name = "Enroll Passkey" }); + public ILocator SignInButton => Page.GetByRole(AriaRole.Button, new() { Name = "Sign in with Passkey" }); + public ILocator ErrorMessage => Page.Locator(".alert-danger"); + + public async Task GotoAsync() => await NavigateToAsync("/login"); + + public async Task WaitForInteractiveAsync() + { + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + // Wait for Blazor circuit: either button must be visible + await Page.WaitForSelectorAsync("button", new PageWaitForSelectorOptions { Timeout = 10_000 }); + } +} diff --git a/Chrono/Tests/PasskeyTests.cs b/Chrono/Tests/PasskeyTests.cs new file mode 100644 index 0000000..cb12036 --- /dev/null +++ b/Chrono/Tests/PasskeyTests.cs @@ -0,0 +1,142 @@ +using System.Text.RegularExpressions; +using Microsoft.Playwright; +using Microsoft.Playwright.NUnit; +using Tests.PageObjects; + +namespace Tests; + +[Parallelizable(ParallelScope.Self)] +[TestFixture] +public class PasskeyTests : PageTest +{ + private const string BaseUrl = "http://localhost:5256"; + private LoginPage _loginPage = null!; + + [SetUp] + public async Task SetUp() + { + _loginPage = new LoginPage(Page); + // Clear any auth cookies and wipe all stored credentials for a clean slate + await Page.Context.ClearCookiesAsync(); + using var http = new HttpClient(); + await http.DeleteAsync($"{BaseUrl}/api/auth/credentials"); + } + + [TearDown] + public async Task TearDown() + { + using var http = new HttpClient(); + await http.DeleteAsync($"{BaseUrl}/api/auth/credentials"); + await Page.Context.ClearCookiesAsync(); + } + + [Test] + public async Task LoginPage_WhenNoCredentials_ShowsEnrollButton() + { + await _loginPage.GotoAsync(); + await _loginPage.WaitForInteractiveAsync(); + + await Expect(_loginPage.EnrollButton).ToBeVisibleAsync(new() { Timeout = 10_000 }); + await Expect(_loginPage.SignInButton).Not.ToBeVisibleAsync(); + } + + [Test] + public async Task EnrollPasskey_CompletesSuccessfully_AndRedirectsHome() + { + var authenticatorId = await Page.Context.AddVirtualAuthenticatorAsync(new VirtualAuthenticatorOptions + { + Protocol = "ctap2", + Transport = "internal", + HasResidentKey = true, + HasUserVerification = true, + IsUserVerified = true, + }); + + try + { + await _loginPage.GotoAsync(); + await _loginPage.WaitForInteractiveAsync(); + + await Expect(_loginPage.EnrollButton).ToBeVisibleAsync(new() { Timeout = 10_000 }); + await _loginPage.EnrollButton.ClickAsync(); + + // After enrollment + auto sign-in, redirects away from /login + await Page.WaitForURLAsync(new Regex(@"^http://localhost:5256(?!/login)"), new() { Timeout = 20_000 }); + } + finally + { + await Page.Context.RemoveVirtualAuthenticatorAsync(authenticatorId); + } + } + + [Test] + public async Task LoginPage_AfterEnrollment_ShowsSignInButton() + { + var authenticatorId = await Page.Context.AddVirtualAuthenticatorAsync(new VirtualAuthenticatorOptions + { + Protocol = "ctap2", + Transport = "internal", + HasResidentKey = true, + HasUserVerification = true, + IsUserVerified = true, + }); + + try + { + // Enroll first + await _loginPage.GotoAsync(); + await _loginPage.WaitForInteractiveAsync(); + await _loginPage.EnrollButton.ClickAsync(); + await Page.WaitForURLAsync(new Regex(@"^http://localhost:5256(?!/login)"), new() { Timeout = 20_000 }); + + // Clear the auth cookie and navigate back to /login + await Page.Context.ClearCookiesAsync(); + await _loginPage.GotoAsync(); + await _loginPage.WaitForInteractiveAsync(); + + // Should show Sign In button (credential is enrolled) + await Expect(_loginPage.SignInButton).ToBeVisibleAsync(new() { Timeout = 10_000 }); + } + finally + { + await Page.Context.RemoveVirtualAuthenticatorAsync(authenticatorId); + } + } + + [Test] + public async Task SignInWithPasskey_AfterEnrollment_AuthenticatesSuccessfully() + { + var authenticatorId = await Page.Context.AddVirtualAuthenticatorAsync(new VirtualAuthenticatorOptions + { + Protocol = "ctap2", + Transport = "internal", + HasResidentKey = true, + HasUserVerification = true, + IsUserVerified = true, + }); + + try + { + // Enroll + await _loginPage.GotoAsync(); + await _loginPage.WaitForInteractiveAsync(); + await _loginPage.EnrollButton.ClickAsync(); + await Page.WaitForURLAsync(new Regex(@"^http://localhost:5256(?!/login)"), new() { Timeout = 20_000 }); + + // Sign out (clear cookie) and return to login + await Page.Context.ClearCookiesAsync(); + await _loginPage.GotoAsync(); + await _loginPage.WaitForInteractiveAsync(); + + await Expect(_loginPage.SignInButton).ToBeVisibleAsync(new() { Timeout = 10_000 }); + await _loginPage.SignInButton.ClickAsync(); + + // After sign-in, redirects away from /login + await Page.WaitForURLAsync(new Regex(@"^http://localhost:5256(?!/login)"), new() { Timeout = 20_000 }); + } + finally + { + await Page.Context.RemoveVirtualAuthenticatorAsync(authenticatorId); + } + } +} diff --git a/Chrono/Tests/PlaywrightTests.cs b/Chrono/Tests/PlaywrightTests.cs index 7787c11..2154813 100644 --- a/Chrono/Tests/PlaywrightTests.cs +++ b/Chrono/Tests/PlaywrightTests.cs @@ -1,14 +1,12 @@ using System.Text.RegularExpressions; using Microsoft.Playwright; -using Microsoft.Playwright.NUnit; namespace Tests; [Parallelizable(ParallelScope.Self)] [TestFixture] -public class PlaywrightTests : PageTest +public class PlaywrightTests : AuthenticatedPageTest { - private const string BaseUrl = "http://localhost:5256"; [Test] public async Task HomePage_ShouldLoad()