Skip to content

Commit 61ee37a

Browse files
committed
fixes, setup fixes
1 parent 69607e2 commit 61ee37a

16 files changed

Lines changed: 4950 additions & 5880 deletions

File tree

src/API/Nocturne.API/Controllers/Authentication/PasskeyController.cs

Lines changed: 6 additions & 192 deletions
Original file line numberDiff line numberDiff line change
@@ -522,175 +522,6 @@ public async Task<IActionResult> CompleteOnboarding()
522522
return NoContent();
523523
}
524524

525-
/// <summary>
526-
/// Generate registration options for the first user during initial setup.
527-
/// Only available when no non-system subjects exist (setup mode).
528-
/// Creates the subject, assigns admin role, and returns passkey registration options.
529-
/// </summary>
530-
[HttpPost("setup/options")]
531-
[AllowAnonymous]
532-
[RemoteCommand]
533-
[ProducesResponseType(typeof(PasskeyOptionsResponse), StatusCodes.Status200OK)]
534-
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)]
535-
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
536-
public async Task<ActionResult<PasskeyOptionsResponse>> SetupOptions(
537-
[FromBody] SetupOptionsRequest request)
538-
{
539-
var tenantId = _tenantAccessor.TenantId;
540-
541-
// Check whether any tenant member already has a passkey credential
542-
var tenantHasPasskeys = await _dbContext.TenantMembers
543-
.Where(m => m.TenantId == tenantId)
544-
.AnyAsync(m => _dbContext.PasskeyCredentials.Any(c => c.SubjectId == m.SubjectId));
545-
if (tenantHasPasskeys)
546-
{
547-
return Problem(detail: "Setup mode is not active", statusCode: 403, title: "Forbidden");
548-
}
549-
550-
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.DisplayName))
551-
{
552-
return Problem(detail: "Username and display name are required", statusCode: 400, title: "Bad Request");
553-
}
554-
555-
// Idempotent: reuse existing setup subject if the WebAuthn ceremony
556-
// failed on a previous attempt (e.g. user scanned QR with phone on localhost)
557-
var existingSubject = await _dbContext.Subjects
558-
.FirstOrDefaultAsync(s => !s.IsSystemSubject && s.IsActive);
559-
560-
Guid subjectId;
561-
if (existingSubject != null)
562-
{
563-
subjectId = existingSubject.Id;
564-
// Update in case the user changed their details between attempts
565-
existingSubject.Name = request.DisplayName.Trim();
566-
existingSubject.Username = request.Username.Trim().ToLowerInvariant();
567-
await _dbContext.SaveChangesAsync();
568-
569-
// Ensure the subject is a member of the current tenant.
570-
// When a tenant is deleted and recreated, the subject persists but
571-
// the TenantMember is cascade-deleted with the old tenant.
572-
var isMember = await _dbContext.TenantMembers
573-
.AnyAsync(tm => tm.TenantId == tenantId && tm.SubjectId == subjectId);
574-
if (!isMember)
575-
{
576-
var ownerRole = await _dbContext.TenantRoles
577-
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Slug == "owner");
578-
if (ownerRole != null)
579-
{
580-
await _tenantService.AddMemberAsync(tenantId, subjectId, [ownerRole.Id]);
581-
}
582-
await _subjectService.AssignRoleAsync(subjectId, "admin");
583-
}
584-
}
585-
else
586-
{
587-
subjectId = Guid.CreateVersion7();
588-
_dbContext.Subjects.Add(new Infrastructure.Data.Entities.SubjectEntity
589-
{
590-
Id = subjectId,
591-
Name = request.DisplayName.Trim(),
592-
Username = request.Username.Trim().ToLowerInvariant(),
593-
IsActive = true,
594-
IsSystemSubject = false,
595-
});
596-
597-
await _dbContext.SaveChangesAsync();
598-
599-
// Add as owner of the default tenant (seeds roles if needed and assigns owner)
600-
var ownerRole = await _dbContext.TenantRoles
601-
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Slug == "owner");
602-
603-
if (ownerRole != null)
604-
{
605-
await _tenantService.AddMemberAsync(tenantId, subjectId, [ownerRole.Id]);
606-
}
607-
608-
// Assign admin role
609-
await _subjectService.AssignRoleAsync(subjectId, "admin");
610-
611-
_logger.LogInformation(
612-
"Setup: created first user {SubjectId} ({Username}) in tenant {TenantId}",
613-
subjectId, request.Username.Trim(), tenantId);
614-
}
615-
616-
// Generate passkey registration options for the new subject
617-
var result = await _passkeyService.GenerateRegistrationOptionsAsync(
618-
subjectId, request.Username.Trim(), tenantId);
619-
620-
return Ok(new PasskeyOptionsResponse
621-
{
622-
Options = result.OptionsJson,
623-
ChallengeToken = result.ChallengeToken,
624-
});
625-
}
626-
627-
/// <summary>
628-
/// Complete passkey registration during initial setup.
629-
/// Verifies attestation, generates recovery codes, issues a full JWT session,
630-
/// and exits setup mode.
631-
/// </summary>
632-
[HttpPost("setup/complete")]
633-
[AllowAnonymous]
634-
[RemoteCommand]
635-
[ProducesResponseType(typeof(SetupCompleteResponse), StatusCodes.Status200OK)]
636-
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)]
637-
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
638-
public async Task<ActionResult<SetupCompleteResponse>> SetupComplete(
639-
[FromBody] SetupCompleteRequest request)
640-
{
641-
var tenantId = _tenantAccessor.TenantId;
642-
643-
// Check whether any tenant member already has a passkey credential
644-
var tenantHasPasskeys = await _dbContext.TenantMembers
645-
.Where(m => m.TenantId == tenantId)
646-
.AnyAsync(m => _dbContext.PasskeyCredentials.Any(c => c.SubjectId == m.SubjectId));
647-
if (tenantHasPasskeys)
648-
{
649-
return Problem(detail: "Setup mode is not active", statusCode: 403, title: "Forbidden");
650-
}
651-
652-
if (string.IsNullOrEmpty(request.ChallengeToken))
653-
{
654-
return Problem(detail: "Challenge token is required", statusCode: 400, title: "Bad Request");
655-
}
656-
657-
try
658-
{
659-
var credResult = await _passkeyService.CompleteRegistrationAsync(
660-
request.AttestationResponseJson, request.ChallengeToken, tenantId);
661-
662-
// Generate recovery codes
663-
var recoveryCodes = await _recoveryCodeService.GenerateCodesAsync(credResult.SubjectId);
664-
665-
var session = await _sessionService.IssueSessionAsync(
666-
credResult.SubjectId,
667-
new SessionContext(
668-
DeviceDescription: "Setup Passkey",
669-
IpAddress: HttpContext.Connection.RemoteIpAddress?.ToString(),
670-
UserAgent: Request.Headers.UserAgent.ToString()));
671-
672-
Response.SetSessionCookies(session, _oidcOptions);
673-
674-
_logger.LogInformation(
675-
"Setup complete: first user {SubjectId} registered with passkey",
676-
credResult.SubjectId);
677-
678-
return Ok(new SetupCompleteResponse
679-
{
680-
Success = true,
681-
RecoveryCodes = recoveryCodes,
682-
AccessToken = session.AccessToken,
683-
RefreshToken = session.RefreshToken,
684-
ExpiresIn = session.ExpiresInSeconds,
685-
});
686-
}
687-
catch (Exception ex)
688-
{
689-
_logger.LogWarning(ex, "Setup passkey registration failed");
690-
return Problem(detail: "Passkey registration failed during setup", statusCode: 400, title: "Registration Failed");
691-
}
692-
}
693-
694525
/// <summary>
695526
/// Begin passkey registration for an anonymous access request.
696527
/// Creates a pending subject and returns WebAuthn registration options.
@@ -896,10 +727,10 @@ public async Task<ActionResult<PasskeyOptionsResponse>> InviteOptions(
896727
[HttpPost("invite/complete")]
897728
[AllowAnonymous]
898729
[RemoteCommand]
899-
[ProducesResponseType(typeof(SetupCompleteResponse), StatusCodes.Status200OK)]
730+
[ProducesResponseType(typeof(PasskeyRegistrationResponse), StatusCodes.Status200OK)]
900731
[ProducesResponseType(StatusCodes.Status404NotFound)]
901732
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
902-
public async Task<ActionResult<SetupCompleteResponse>> InviteComplete(
733+
public async Task<ActionResult<PasskeyRegistrationResponse>> InviteComplete(
903734
[FromBody] InviteCompleteRequest request,
904735
[FromServices] IMemberInviteService memberInviteService)
905736
{
@@ -938,7 +769,7 @@ public async Task<ActionResult<SetupCompleteResponse>> InviteComplete(
938769
"Invite complete: subject {SubjectId} registered with passkey via invite",
939770
credResult.SubjectId);
940771

941-
return Ok(new SetupCompleteResponse
772+
return Ok(new PasskeyRegistrationResponse
942773
{
943774
Success = true,
944775
RecoveryCodes = recoveryCodes,
@@ -1090,27 +921,10 @@ public class AuthStatusResponse
1090921
}
1091922

1092923
/// <summary>
1093-
/// Request for initial setup registration options (first user creation)
1094-
/// </summary>
1095-
public class SetupOptionsRequest
1096-
{
1097-
public string Username { get; set; } = string.Empty;
1098-
public string DisplayName { get; set; } = string.Empty;
1099-
}
1100-
1101-
/// <summary>
1102-
/// Request to complete initial setup registration
1103-
/// </summary>
1104-
public class SetupCompleteRequest
1105-
{
1106-
public string AttestationResponseJson { get; set; } = string.Empty;
1107-
public string ChallengeToken { get; set; } = string.Empty;
1108-
}
1109-
1110-
/// <summary>
1111-
/// Response for completed setup registration
924+
/// Response for a completed passkey registration that issues a session
925+
/// (recovery codes plus session tokens).
1112926
/// </summary>
1113-
public class SetupCompleteResponse
927+
public class PasskeyRegistrationResponse
1114928
{
1115929
public bool Success { get; set; }
1116930
public List<string> RecoveryCodes { get; set; } = new();

src/Web/packages/app/src/lib/api/generated/index.ts

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)