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." }); } }