Vibe passkey

This commit is contained in:
6d486f49
2026-07-03 14:31:40 -04:00
parent e1cb1a81a3
commit ed95535e0a
25 changed files with 917 additions and 15 deletions
+9 -1
View File
@@ -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 ' *)"
]
}
}
+1
View File
@@ -12,6 +12,7 @@ public class AppDbContext : DbContext
public DbSet<CardNote> CardNotes { get; set; }
public DbSet<UserDeck> UserDecks { get; set; }
public DbSet<PasskeyCredential> PasskeyCredentials { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
+4 -1
View File
@@ -15,12 +15,15 @@
<link href="/css/app.css" rel="stylesheet"/>
<link href="/Server.styles.css" rel="stylesheet"/>
<script defer src="/_content/Telerik.UI.for.Blazor/js/telerik-blazor.js"></script>
<script src="/js/passkey.js"></script>
<link href="/favicon.png" rel="icon" type="image/png"/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<CascadingAuthenticationState>
<Routes/>
</CascadingAuthenticationState>
<script src="_framework/blazor.web.js"></script>
</body>
+110
View File
@@ -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
<PageTitle>Sign In — Chrono CCG</PageTitle>
<div class="login-container">
<div class="login-card">
<h1 class="login-title">Chrono CCG</h1>
@if (_error is not null)
{
<div class="alert alert-danger">@_error</div>
}
@if (_enrolled)
{
<p class="login-hint">Use your registered passkey to sign in.</p>
<button class="btn btn-primary btn-lg w-100" @onclick="SignIn" disabled="@_busy">
@if (_busy)
{
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
}
Sign in with Passkey
</button>
}
else
{
<p class="login-hint">No passkey enrolled yet. Register your device to get started.</p>
<button class="btn btn-success btn-lg w-100" @onclick="Enroll" disabled="@_busy">
@if (_busy)
{
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
}
Enroll Passkey
</button>
}
</div>
</div>
@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<StatusResponse>("/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);
}
@@ -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;
}
@@ -0,0 +1,8 @@
@inject NavigationManager Nav
@code {
protected override void OnInitialized()
{
Nav.NavigateTo("/login", forceLoad: true);
}
}
+5 -1
View File
@@ -1,6 +1,10 @@
<Router AppAssembly="@typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Home).Assembly }">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<RedirectToLogin/>
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
</Found>
</Router>
+2
View File
@@ -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
+219
View File
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Ok();
}
// ── Status ────────────────────────────────────────────────────────────────
[HttpGet("status")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> DeleteAllCredentials([FromServices] IWebHostEnvironment env)
{
if (!env.IsDevelopment()) return NotFound();
_db.PasskeyCredentials.RemoveRange(_db.PasskeyCredentials);
await _db.SaveChangesAsync();
return Ok(new { message = "All credentials deleted." });
}
}
@@ -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
@@ -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
@@ -0,0 +1,110 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("CardName")
.HasColumnType("text");
b.Property<string>("Note")
.IsRequired()
.HasColumnType("text");
b.HasKey("CardName");
b.ToTable("CardNotes");
});
modelBuilder.Entity("Server.Models.PasskeyCredential", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("AaGuid")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("CredentialId")
.IsRequired()
.HasColumnType("bytea");
b.Property<byte[]>("PublicKey")
.IsRequired()
.HasColumnType("bytea");
b.Property<long>("SignCount")
.HasColumnType("bigint");
b.Property<byte[]>("UserHandle")
.IsRequired()
.HasColumnType("bytea");
b.HasKey("Id");
b.ToTable("PasskeyCredentials");
});
modelBuilder.Entity("Server.Models.UserDeck", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.PrimitiveCollection<string>("Cards")
.IsRequired()
.HasColumnType("jsonb");
b.PrimitiveCollection<string>("Divers")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("Season")
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("UserDecks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,41 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Server.Migrations
{
/// <inheritdoc />
public partial class AddPasskeyCredentials : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PasskeyCredentials",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CredentialId = table.Column<byte[]>(type: "bytea", nullable: false),
PublicKey = table.Column<byte[]>(type: "bytea", nullable: false),
SignCount = table.Column<long>(type: "bigint", nullable: false),
UserHandle = table.Column<byte[]>(type: "bytea", nullable: false),
AaGuid = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PasskeyCredentials", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PasskeyCredentials");
}
}
}
@@ -36,6 +36,40 @@ namespace Server.Migrations
b.ToTable("CardNotes");
});
modelBuilder.Entity("Server.Models.PasskeyCredential", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("AaGuid")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("CredentialId")
.IsRequired()
.HasColumnType("bytea");
b.Property<byte[]>("PublicKey")
.IsRequired()
.HasColumnType("bytea");
b.Property<long>("SignCount")
.HasColumnType("bigint");
b.Property<byte[]>("UserHandle")
.IsRequired()
.HasColumnType("bytea");
b.HasKey("Id");
b.ToTable("PasskeyCredentials");
});
modelBuilder.Entity("Server.Models.UserDeck", b =>
{
b.Property<Guid>("Id")
+12
View File
@@ -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;
}
+34 -1
View File
@@ -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<CardRenderingService>();
builder.Services.AddScoped<AnalyticsService>();
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<HashSet<string>>()!;
options.TimestampDriftTolerance = 300_000;
});
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<AppDbContext>(options =>
@@ -23,7 +54,6 @@ builder.Services.AddDbContext<AppDbContext>(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();
+2
View File
@@ -6,6 +6,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fido2" Version="4.0.1"/>
<PackageReference Include="Fido2.AspNet" Version="4.0.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
+6 -1
View File
@@ -5,5 +5,10 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"Fido2": {
"ServerDomain": "localhost",
"ServerName": "Chrono CCG",
"Origins": [ "https://localhost:7001", "http://localhost:5000" ]
}
}
+99
View File
@@ -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();
};
+20
View File
@@ -0,0 +1,20 @@
using Microsoft.Playwright.NUnit;
namespace Tests;
/// <summary>
/// Base class for Playwright tests that require an authenticated session.
/// Signs in via the dev-only bypass endpoint before each test.
/// </summary>
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.");
}
}
+1 -3
View File
@@ -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]
+2 -3
View File
@@ -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()
+21
View File
@@ -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 });
}
}
+142
View File
@@ -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);
}
}
}
+1 -3
View File
@@ -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()