@@ -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 ( ) ;
0 commit comments