# Passkey MFA Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add passkeys (WebAuthn) as an MFA factor alongside existing TOTP, with hierarchical auth policy enforcement and rich device management. **Architecture:** Logto-native WebAuthn via Experience API (sign-in) and Management API (settings-page enrollment). Two independent policy domains — vendor controls platform logins, tenant controls org user logins. All credential storage stays in Logto; SaaS backend adds policy enforcement and exposes Logto data through its own API. **Tech Stack:** Spring Boot 3, Logto Experience API + Management API, React 19, @tanstack/react-query, @simplewebauthn/browser, @cameleer/design-system --- ## File Map ### Backend (new files) - `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyEntity.java` — JPA entity for `vendor_auth_policy` table - `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyRepository.java` — Spring Data JPA repository - `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyController.java` — REST endpoints for vendor auth policy (separate from existing `VendorTenantController`) - `src/main/resources/db/migration/V003__passkey_mfa_support.sql` — Migration: create `vendor_auth_policy` table with seed row ### Backend (modified files) - `src/.../config/MfaEnforcementFilter.java` — Expand route matching and policy lookup - `src/.../config/SecurityConfig.java` — Add new exempt routes - `src/.../config/PublicConfigController.java` — Expose vendor auth policy in `/api/config` - `src/.../identity/LogtoManagementClient.java` — Add WebAuthn credential CRUD + custom data methods - `src/.../portal/TenantPortalService.java` — Add WebAuthn status/list methods, extend settings whitelist - `src/.../portal/TenantPortalController.java` — Add WebAuthn endpoints, extend mfa-policy response - `docker/logto-bootstrap.sh` — Update Custom JWT script ### Frontend — Sign-in UI (modified files) - `ui/sign-in/src/experience-api.ts` — Add WebAuthn verification functions - `ui/sign-in/src/SignInPage.tsx` — Add WebAuthn and method-picker modes ### Frontend — Sign-in UI (new files) - `ui/sign-in/package.json` — Add `@simplewebauthn/browser` dependency ### Frontend — Platform UI (modified files) - `ui/src/types/api.ts` — Add passkey types - `ui/src/api/tenant-hooks.ts` — Add passkey hooks - `ui/src/api/vendor-hooks.ts` — Add vendor auth policy hooks - `ui/src/api/client.ts` — Handle `APP_PASSKEY_REQUIRED` error code - `ui/src/pages/tenant/SettingsPage.tsx` — Add PasskeySection + AuthPolicySection components - `ui/src/pages/OnboardingPage.tsx` — Add optional passkey step - `ui/src/router.tsx` — Add vendor auth policy route ### Frontend — Platform UI (new files) - `ui/src/pages/vendor/AuthPolicyPage.tsx` — Vendor auth policy management page - `ui/src/pages/vendor/AuthPolicyPage.module.css` — Styles for auth policy page - `ui/package.json` — Add `@simplewebauthn/browser` dependency ### Important: Passkey Registration Limitation Passkey **registration** (creating a new credential) can only happen during a Logto Experience API interaction — i.e., during sign-in or when Logto prompts for MFA binding. The Logto Management API's `POST /api/users/{userId}/mfa-verifications` with `type: "WebAuthn"` requires browser-side WebAuthn ceremony that must be initiated through the Experience API, not the Management API. This means: - The **settings page** can list, rename, and delete passkeys but **cannot register new ones** - New passkey registration happens during **sign-in** when Logto offers MFA binding - The **post-sign-in nudge** and **onboarding** route users to the next sign-in where Logto will offer WebAuthn binding - This is consistent with Approach A — we work within what Logto provides --- ### Task 1: Database Migration — vendor_auth_policy Table **Files:** - Create: `src/main/resources/db/migration/V003__passkey_mfa_support.sql` - [ ] **Step 1: Write the migration SQL** ```sql -- V003__passkey_mfa_support.sql -- Adds vendor-level auth policy table for platform login enforcement CREATE TABLE IF NOT EXISTS vendor_auth_policy ( id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), mfa_mode VARCHAR(10) NOT NULL DEFAULT 'off', passkey_enabled BOOLEAN NOT NULL DEFAULT false, passkey_mode VARCHAR(10) NOT NULL DEFAULT 'optional', updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Seed with default (no enforcement) INSERT INTO vendor_auth_policy (id) VALUES (1) ON CONFLICT DO NOTHING; ``` - [ ] **Step 2: Verify migration runs** Run: `./mvnw flyway:info -q` (or start the app and check logs for `V003__passkey_mfa_support`) Expected: V003 listed as pending or applied - [ ] **Step 3: Commit** ```bash git add src/main/resources/db/migration/V003__passkey_mfa_support.sql git commit -m "feat: add vendor_auth_policy table for passkey MFA support" ``` --- ### Task 2: VendorAuthPolicy Entity and Repository **Files:** - Create: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyEntity.java` - Create: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyRepository.java` - [ ] **Step 1: Create the JPA entity** ```java package net.siegeln.cameleer.saas.vendor; import jakarta.persistence.*; import java.time.Instant; @Entity @Table(name = "vendor_auth_policy") public class VendorAuthPolicyEntity { @Id @Column(name = "id") private Integer id = 1; @Column(name = "mfa_mode", nullable = false) private String mfaMode = "off"; @Column(name = "passkey_enabled", nullable = false) private boolean passkeyEnabled = false; @Column(name = "passkey_mode", nullable = false) private String passkeyMode = "optional"; @Column(name = "updated_at", nullable = false) private Instant updatedAt = Instant.now(); @PreUpdate void onUpdate() { this.updatedAt = Instant.now(); } // Getters and setters public Integer getId() { return id; } public String getMfaMode() { return mfaMode; } public void setMfaMode(String mfaMode) { this.mfaMode = mfaMode; } public boolean isPasskeyEnabled() { return passkeyEnabled; } public void setPasskeyEnabled(boolean passkeyEnabled) { this.passkeyEnabled = passkeyEnabled; } public String getPasskeyMode() { return passkeyMode; } public void setPasskeyMode(String passkeyMode) { this.passkeyMode = passkeyMode; } public Instant getUpdatedAt() { return updatedAt; } } ``` - [ ] **Step 2: Create the repository** ```java package net.siegeln.cameleer.saas.vendor; import org.springframework.data.jpa.repository.JpaRepository; public interface VendorAuthPolicyRepository extends JpaRepository { default VendorAuthPolicyEntity getPolicy() { return findById(1).orElseGet(() -> { var policy = new VendorAuthPolicyEntity(); return save(policy); }); } } ``` - [ ] **Step 3: Verify compilation** Run: `./mvnw compile -q` Expected: BUILD SUCCESS - [ ] **Step 4: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyEntity.java \ src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyRepository.java git commit -m "feat: add VendorAuthPolicy entity and repository" ``` --- ### Task 3: VendorAuthPolicyController **Files:** - Create: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyController.java` - [ ] **Step 1: Create the controller** ```java package net.siegeln.cameleer.saas.vendor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.Map; import java.util.Set; @RestController @RequestMapping("/api/vendor/auth-policy") @PreAuthorize("hasAuthority('SCOPE_platform:admin')") public class VendorAuthPolicyController { private static final Set VALID_MFA_MODES = Set.of("off", "optional", "required"); private static final Set VALID_PASSKEY_MODES = Set.of("optional", "preferred", "required"); private final VendorAuthPolicyRepository repository; public VendorAuthPolicyController(VendorAuthPolicyRepository repository) { this.repository = repository; } public record AuthPolicyResponse(String mfaMode, boolean passkeyEnabled, String passkeyMode) { static AuthPolicyResponse from(VendorAuthPolicyEntity entity) { return new AuthPolicyResponse(entity.getMfaMode(), entity.isPasskeyEnabled(), entity.getPasskeyMode()); } } public record AuthPolicyUpdateRequest(String mfaMode, Boolean passkeyEnabled, String passkeyMode) {} @GetMapping public ResponseEntity getPolicy() { return ResponseEntity.ok(AuthPolicyResponse.from(repository.getPolicy())); } @PutMapping public ResponseEntity updatePolicy(@RequestBody AuthPolicyUpdateRequest request) { var policy = repository.getPolicy(); if (request.mfaMode() != null) { if (!VALID_MFA_MODES.contains(request.mfaMode())) { return ResponseEntity.badRequest().build(); } policy.setMfaMode(request.mfaMode()); } if (request.passkeyEnabled() != null) { policy.setPasskeyEnabled(request.passkeyEnabled()); } if (request.passkeyMode() != null) { if (!VALID_PASSKEY_MODES.contains(request.passkeyMode())) { return ResponseEntity.badRequest().build(); } policy.setPasskeyMode(request.passkeyMode()); } repository.save(policy); return ResponseEntity.ok(AuthPolicyResponse.from(policy)); } } ``` - [ ] **Step 2: Verify compilation** Run: `./mvnw compile -q` Expected: BUILD SUCCESS - [ ] **Step 3: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyController.java git commit -m "feat: add vendor auth policy REST endpoints" ``` --- ### Task 4: Extend LogtoManagementClient with WebAuthn Methods **Files:** - Modify: `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java` - [ ] **Step 1: Add WebAuthn credential methods** After the existing `deleteAllMfaVerifications` method (line 605), add: ```java /** List WebAuthn credentials for a user (filtered from all MFA verifications). */ @SuppressWarnings("unchecked") public List> getWebAuthnCredentials(String userId) { var all = getUserMfaVerifications(userId); return all.stream() .filter(v -> "WebAuthn".equals(String.valueOf(v.get("type")))) .toList(); } /** Rename a WebAuthn credential. Uses PATCH on the MFA verification. */ public void renameMfaVerification(String userId, String verificationId, String name) { if (!isAvailable()) return; try { restClient.patch() .uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications/" + verificationId) .header("Authorization", "Bearer " + getAccessToken()) .contentType(MediaType.APPLICATION_JSON) .body(Map.of("name", name)) .retrieve() .toBodilessEntity(); } catch (Exception e) { log.warn("Failed to rename MFA verification {} for user {}: {}", verificationId, userId, e.getMessage()); } } /** Update user custom data (partial merge). Used for mfa_method_preference. */ @SuppressWarnings("unchecked") public void updateUserCustomData(String userId, Map customData) { if (!isAvailable()) return; try { restClient.patch() .uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/custom-data") .header("Authorization", "Bearer " + getAccessToken()) .contentType(MediaType.APPLICATION_JSON) .body(customData) .retrieve() .toBodilessEntity(); } catch (Exception e) { log.warn("Failed to update custom data for user {}: {}", userId, e.getMessage()); } } ``` - [ ] **Step 2: Verify compilation** Run: `./mvnw compile -q` Expected: BUILD SUCCESS - [ ] **Step 3: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java git commit -m "feat: add WebAuthn credential and custom data methods to LogtoManagementClient" ``` --- ### Task 5: Extend TenantPortalService with Passkey Methods and Auth Settings **Files:** - Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java` - [ ] **Step 1: Add passkey status to MfaStatusData** Replace the existing `MfaStatusData` record (around line 80): ```java public record MfaStatusData(boolean enrolled, boolean hasBackupCodes, boolean passkeyEnrolled, int passkeyCount) {} ``` - [ ] **Step 2: Update getMfaStatus to include passkey info** Replace the existing `getMfaStatus` method (lines 294-301): ```java public MfaStatusData getMfaStatus(String userId) { var verifications = logtoClient.getUserMfaVerifications(userId); boolean enrolled = verifications.stream() .anyMatch(v -> "Totp".equals(String.valueOf(v.get("type")))); boolean hasBackupCodes = verifications.stream() .anyMatch(v -> "BackupCode".equals(String.valueOf(v.get("type")))); long passkeyCount = verifications.stream() .filter(v -> "WebAuthn".equals(String.valueOf(v.get("type")))) .count(); return new MfaStatusData(enrolled, hasBackupCodes, passkeyCount > 0, (int) passkeyCount); } ``` - [ ] **Step 3: Add passkey credential list/rename/delete methods** After the `resetTeamMemberMfa` method (after line 371), add: ```java // --- Passkey methods --- public record PasskeyCredential(String id, String name, String agent, String createdAt) {} @SuppressWarnings("unchecked") public List listPasskeys(String userId) { return logtoClient.getWebAuthnCredentials(userId).stream() .map(v -> new PasskeyCredential( String.valueOf(v.get("id")), v.get("name") != null ? String.valueOf(v.get("name")) : null, v.get("agent") != null ? String.valueOf(v.get("agent")) : null, v.get("createdAt") != null ? String.valueOf(v.get("createdAt")) : null )) .toList(); } public void renamePasskey(String userId, String credentialId, String name) { // Verify the credential belongs to this user and is WebAuthn type var credentials = logtoClient.getWebAuthnCredentials(userId); boolean owns = credentials.stream() .anyMatch(v -> credentialId.equals(String.valueOf(v.get("id")))); if (!owns) { throw new IllegalArgumentException("Credential not found"); } logtoClient.renameMfaVerification(userId, credentialId, name); } public void deletePasskey(String userId, String credentialId) { var credentials = logtoClient.getWebAuthnCredentials(userId); boolean owns = credentials.stream() .anyMatch(v -> credentialId.equals(String.valueOf(v.get("id")))); if (!owns) { throw new IllegalArgumentException("Credential not found"); } logtoClient.deleteMfaVerification(userId, credentialId); } public void updateMfaMethodPreference(String userId, String preference) { logtoClient.updateUserCustomData(userId, Map.of("mfa_method_preference", preference)); } ``` - [ ] **Step 4: Extend updateTenantSettings whitelist** Replace the existing `updateTenantSettings` method (lines 373-383): ```java public void updateTenantSettings(Map updates) { TenantEntity tenant = resolveTenant(); Map settings = new HashMap<>( tenant.getSettings() != null ? tenant.getSettings() : Map.of()); // Only allow known keys if (updates.containsKey("mfaRequired")) { settings.put("mfaRequired", Boolean.TRUE.equals(updates.get("mfaRequired"))); } if (updates.containsKey("mfaMode")) { String mode = String.valueOf(updates.get("mfaMode")); if (Set.of("off", "optional", "required").contains(mode)) { settings.put("mfaMode", mode); } } if (updates.containsKey("passkeyEnabled")) { settings.put("passkeyEnabled", Boolean.TRUE.equals(updates.get("passkeyEnabled"))); } if (updates.containsKey("passkeyMode")) { String mode = String.valueOf(updates.get("passkeyMode")); if (Set.of("optional", "preferred", "required").contains(mode)) { settings.put("passkeyMode", mode); } } tenant.setSettings(settings); tenantService.save(tenant); } ``` - [ ] **Step 5: Add auth settings data method** After `updateTenantSettings`, add: ```java public record AuthSettingsData(String mfaMode, boolean passkeyEnabled, String passkeyMode) {} public AuthSettingsData getAuthSettings() { TenantEntity tenant = resolveTenant(); Map settings = tenant.getSettings() != null ? tenant.getSettings() : Map.of(); String mfaMode = settings.containsKey("mfaMode") ? String.valueOf(settings.get("mfaMode")) : (Boolean.TRUE.equals(settings.get("mfaRequired")) ? "required" : "off"); boolean passkeyEnabled = Boolean.TRUE.equals(settings.get("passkeyEnabled")); String passkeyMode = settings.containsKey("passkeyMode") ? String.valueOf(settings.get("passkeyMode")) : "optional"; return new AuthSettingsData(mfaMode, passkeyEnabled, passkeyMode); } ``` - [ ] **Step 6: Add `Set` import if not present** Ensure this import exists at the top of the file: ```java import java.util.Set; ``` - [ ] **Step 7: Verify compilation** Run: `./mvnw compile -q` Expected: BUILD SUCCESS - [ ] **Step 8: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java git commit -m "feat: add passkey management and auth settings to TenantPortalService" ``` --- ### Task 6: Extend TenantPortalController with Passkey and Auth Settings Endpoints **Files:** - Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java` - [ ] **Step 1: Add passkey management endpoints** After the existing `resetTeamMemberMfa` endpoint (after line 186), add: ```java // --- Passkey endpoints --- @GetMapping("/mfa/webauthn") public ResponseEntity> listPasskeys( @AuthenticationPrincipal Jwt jwt) { return ResponseEntity.ok(portalService.listPasskeys(jwt.getSubject())); } @PatchMapping("/mfa/webauthn/{id}/name") public ResponseEntity renamePasskey(@AuthenticationPrincipal Jwt jwt, @PathVariable String id, @RequestBody Map body) { String name = body.get("name"); if (name == null || name.isBlank()) { return ResponseEntity.badRequest().build(); } try { portalService.renamePasskey(jwt.getSubject(), id, name); return ResponseEntity.noContent().build(); } catch (IllegalArgumentException e) { return ResponseEntity.notFound().build(); } } @DeleteMapping("/mfa/webauthn/{id}") public ResponseEntity deletePasskey(@AuthenticationPrincipal Jwt jwt, @PathVariable String id) { try { portalService.deletePasskey(jwt.getSubject(), id); return ResponseEntity.noContent().build(); } catch (IllegalArgumentException e) { return ResponseEntity.notFound().build(); } } @PostMapping("/mfa/method-preference") public ResponseEntity updateMfaMethodPreference(@AuthenticationPrincipal Jwt jwt, @RequestBody Map body) { String preference = body.get("preference"); if (preference == null || !Set.of("totp", "webauthn").contains(preference)) { return ResponseEntity.badRequest().build(); } portalService.updateMfaMethodPreference(jwt.getSubject(), preference); return ResponseEntity.noContent().build(); } ``` - [ ] **Step 2: Add auth settings endpoints** After the passkey endpoints, add: ```java // --- Auth settings endpoints --- @GetMapping("/auth-settings") public ResponseEntity getAuthSettings() { return ResponseEntity.ok(portalService.getAuthSettings()); } @PutMapping("/auth-settings") public ResponseEntity updateAuthSettings(@RequestBody Map updates) { portalService.updateTenantSettings(updates); return ResponseEntity.ok().build(); } ``` - [ ] **Step 3: Extend the mfa-policy endpoint** Replace the existing `getMfaPolicy` method (lines 194-204): ```java @GetMapping("/{slug}/mfa-policy") public ResponseEntity> getMfaPolicy(@PathVariable String slug) { var tenantOpt = tenantService.getBySlug(slug); if (tenantOpt.isEmpty()) { return ResponseEntity.notFound().build(); } var tenant = tenantOpt.get(); Map settings = tenant.getSettings() != null ? tenant.getSettings() : Map.of(); // Support both old mfaRequired and new mfaMode keys String mfaMode = settings.containsKey("mfaMode") ? String.valueOf(settings.get("mfaMode")) : (Boolean.TRUE.equals(settings.get("mfaRequired")) ? "required" : "off"); boolean passkeyEnabled = Boolean.TRUE.equals(settings.get("passkeyEnabled")); String passkeyMode = settings.containsKey("passkeyMode") ? String.valueOf(settings.get("passkeyMode")) : "optional"; return ResponseEntity.ok(Map.of( "mfaRequired", "required".equals(mfaMode), "mfaMode", mfaMode, "passkeyEnabled", passkeyEnabled, "passkeyMode", passkeyMode )); } ``` - [ ] **Step 4: Add required imports** ```java import java.util.List; import java.util.Set; ``` - [ ] **Step 5: Verify compilation** Run: `./mvnw compile -q` Expected: BUILD SUCCESS - [ ] **Step 6: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java git commit -m "feat: add passkey and auth settings endpoints to TenantPortalController" ``` --- ### Task 7: Expand MfaEnforcementFilter for Vendor Policy and Passkey Checks **Files:** - Modify: `src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java` - [ ] **Step 1: Rewrite the filter** Replace the entire file contents: ```java package net.siegeln.cameleer.saas.config; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import net.siegeln.cameleer.saas.tenant.TenantService; import net.siegeln.cameleer.saas.vendor.VendorAuthPolicyRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.MediaType; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.Map; import java.util.Set; @Component public class MfaEnforcementFilter extends OncePerRequestFilter { private static final Logger log = LoggerFactory.getLogger(MfaEnforcementFilter.class); private static final Set EXEMPT_PREFIXES = Set.of( "/api/tenant/mfa/", "/api/config", "/api/me", "/api/onboarding", "/api/vendor/auth-policy", "/api/tenant/auth-settings" ); private final TenantService tenantService; private final VendorAuthPolicyRepository vendorPolicyRepo; private final ObjectMapper objectMapper; public MfaEnforcementFilter(TenantService tenantService, VendorAuthPolicyRepository vendorPolicyRepo, ObjectMapper objectMapper) { this.tenantService = tenantService; this.vendorPolicyRepo = vendorPolicyRepo; this.objectMapper = objectMapper; } @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getServletPath(); boolean isProtected = path.startsWith("/api/tenant/") || path.startsWith("/api/vendor/") || path.startsWith("/api/portal/"); if (!isProtected) return true; return EXEMPT_PREFIXES.stream().anyMatch(path::startsWith); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { var auth = SecurityContextHolder.getContext().getAuthentication(); if (!(auth instanceof JwtAuthenticationToken jwtAuth)) { filterChain.doFilter(request, response); return; } Jwt jwt = jwtAuth.getToken(); String path = request.getServletPath(); if (path.startsWith("/api/vendor/") || path.startsWith("/api/portal/")) { enforceVendorPolicy(jwt, request, response, filterChain); } else if (path.startsWith("/api/tenant/")) { enforceTenantPolicy(jwt, request, response, filterChain); } else { filterChain.doFilter(request, response); } } private void enforceVendorPolicy(Jwt jwt, HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { var policy = vendorPolicyRepo.getPolicy(); Boolean mfaEnrolled = jwt.getClaim("mfa_enrolled"); Boolean passkeyEnrolled = jwt.getClaim("passkey_enrolled"); if ("required".equals(policy.getMfaMode()) && !Boolean.TRUE.equals(mfaEnrolled)) { log.info("MFA enforcement (vendor): blocking user {} — vendor policy requires MFA", jwt.getSubject()); writeError(response, "APP_MFA_REQUIRED", "mfa_enrollment_required", "Platform authentication policy requires multi-factor authentication"); return; } if (policy.isPasskeyEnabled() && "required".equals(policy.getPasskeyMode()) && !Boolean.TRUE.equals(passkeyEnrolled)) { log.info("Passkey enforcement (vendor): blocking user {} — vendor policy requires passkey", jwt.getSubject()); writeError(response, "APP_PASSKEY_REQUIRED", "passkey_enrollment_required", "Platform authentication policy requires a passkey"); return; } filterChain.doFilter(request, response); } private void enforceTenantPolicy(Jwt jwt, HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { Boolean mfaEnrolled = jwt.getClaim("mfa_enrolled"); Boolean passkeyEnrolled = jwt.getClaim("passkey_enrolled"); String orgId = jwt.getClaimAsString("organization_id"); if (orgId == null) { filterChain.doFilter(request, response); return; } var tenant = tenantService.getByLogtoOrgId(orgId).orElse(null); if (tenant == null) { filterChain.doFilter(request, response); return; } Map settings = tenant.getSettings() != null ? tenant.getSettings() : Map.of(); // Resolve effective MFA mode (new mfaMode key takes precedence over legacy mfaRequired) String mfaMode = settings.containsKey("mfaMode") ? String.valueOf(settings.get("mfaMode")) : (Boolean.TRUE.equals(settings.get("mfaRequired")) ? "required" : "off"); if ("required".equals(mfaMode) && !Boolean.TRUE.equals(mfaEnrolled)) { log.info("MFA enforcement: blocking user {} — tenant {} requires MFA", jwt.getSubject(), tenant.getSlug()); writeError(response, "APP_MFA_REQUIRED", "mfa_enrollment_required", "Your organization requires multi-factor authentication"); return; } boolean passkeyEnabled = Boolean.TRUE.equals(settings.get("passkeyEnabled")); String passkeyMode = settings.containsKey("passkeyMode") ? String.valueOf(settings.get("passkeyMode")) : "optional"; if (passkeyEnabled && "required".equals(passkeyMode) && !Boolean.TRUE.equals(passkeyEnrolled)) { log.info("Passkey enforcement: blocking user {} — tenant {} requires passkey", jwt.getSubject(), tenant.getSlug()); writeError(response, "APP_PASSKEY_REQUIRED", "passkey_enrollment_required", "Your organization requires a passkey"); return; } filterChain.doFilter(request, response); } private void writeError(HttpServletResponse response, String errorCode, String code, String message) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setHeader("X-Cameleer-Error", errorCode); objectMapper.writeValue(response.getOutputStream(), Map.of( "error", errorCode, "code", code, "message", message )); } } ``` - [ ] **Step 2: Verify compilation** Run: `./mvnw compile -q` Expected: BUILD SUCCESS - [ ] **Step 3: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java git commit -m "feat: expand MfaEnforcementFilter for vendor policy and passkey checks" ``` --- ### Task 8: Extend PublicConfigController with Vendor Auth Policy **Files:** - Modify: `src/main/java/net/siegeln/cameleer/saas/config/PublicConfigController.java` - [ ] **Step 1: Inject VendorAuthPolicyRepository and extend config response** Add the repository field and update the `config()` method: ```java private final VendorAuthPolicyRepository vendorPolicyRepo; public PublicConfigController(VendorAuthPolicyRepository vendorPolicyRepo) { this.vendorPolicyRepo = vendorPolicyRepo; } ``` Replace the `config()` method's return statement (the `return Map.of(...)` at line 64): ```java var policy = vendorPolicyRepo.getPolicy(); var vendorAuthPolicy = Map.of( "mfaMode", policy.getMfaMode(), "passkeyEnabled", policy.isPasskeyEnabled(), "passkeyMode", policy.getPasskeyMode() ); return Map.of( "logtoEndpoint", endpoint, "logtoClientId", clientId != null ? clientId : "", "logtoResource", apiResource, "scopes", SCOPES, "vendorAuthPolicy", vendorAuthPolicy ); ``` Add the import: ```java import net.siegeln.cameleer.saas.vendor.VendorAuthPolicyRepository; ``` - [ ] **Step 2: Verify compilation** Run: `./mvnw compile -q` Expected: BUILD SUCCESS - [ ] **Step 3: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/config/PublicConfigController.java git commit -m "feat: expose vendor auth policy in public config endpoint" ``` --- ### Task 9: Update Custom JWT Script in Bootstrap **Files:** - Modify: `docker/logto-bootstrap.sh` - [ ] **Step 1: Update the Custom JWT script** Replace the `CUSTOM_JWT_SCRIPT` variable (lines 541-561): ```bash CUSTOM_JWT_SCRIPT='const getCustomJwtClaims = async ({ token, context, environmentVariables }) => { const roleMap = { owner: "server:admin", operator: "server:operator", viewer: "server:viewer" }; const roles = new Set(); if (context?.user?.organizationRoles) { for (const orgRole of context.user.organizationRoles) { const mapped = roleMap[orgRole.roleName]; if (mapped) roles.add(mapped); } } if (context?.user?.roles) { for (const role of context.user.roles) { if (role.name === "saas-vendor") roles.add("server:admin"); } } const mfaFactors = context?.user?.mfaVerificationFactors || []; const mfaEnrolled = mfaFactors.some(f => f.type === "Totp" || f.type === "WebAuthn"); const passkeyEnrolled = mfaFactors.some(f => f.type === "WebAuthn"); const claims = {}; if (roles.size > 0) claims.roles = [...roles]; claims.mfa_enrolled = mfaEnrolled; claims.passkey_enrolled = passkeyEnrolled; claims.mfa_method_preference = context?.user?.customData?.mfa_method_preference || null; return claims; };' ``` - [ ] **Step 2: Commit** ```bash git add docker/logto-bootstrap.sh git commit -m "feat: add passkey_enrolled and mfa_method_preference to Custom JWT claims" ``` --- ### Task 10: Frontend Types and API Client Updates **Files:** - Modify: `ui/src/types/api.ts` - Modify: `ui/src/api/client.ts` - [ ] **Step 1: Add passkey types to api.ts** After the existing `BackupCodesResponse` interface (line 257), add: ```typescript export interface PasskeyCredential { id: string; name: string | null; agent: string | null; createdAt: string | null; } export interface AuthPolicy { mfaMode: string; passkeyEnabled: boolean; passkeyMode: string; } ``` Update `MfaStatus` to include passkey info: ```typescript export interface MfaStatus { enrolled: boolean; hasBackupCodes: boolean; passkeyEnrolled: boolean; passkeyCount: number; } ``` Update `TenantSettings` to include auth settings: ```typescript export interface TenantSettings { name: string; slug: string; tier: string; status: string; serverEndpoint: string | null; createdAt: string; mfaRequired?: boolean; mfaMode?: string; passkeyEnabled?: boolean; passkeyMode?: string; } ``` - [ ] **Step 2: Handle APP_PASSKEY_REQUIRED in client.ts** In `apiFetch`, extend the 403 handling (after the `APP_MFA_REQUIRED` block at line 68-71): ```typescript if (errorHeader === 'APP_PASSKEY_REQUIRED') { window.location.href = '/platform/tenant/settings?passkey=required'; throw new ApiError(403, '{"message":"Passkey enrollment required"}'); } ``` - [ ] **Step 3: Verify build** Run: `cd ui && npm run build` Expected: No type errors - [ ] **Step 4: Commit** ```bash git add ui/src/types/api.ts ui/src/api/client.ts git commit -m "feat: add passkey types and APP_PASSKEY_REQUIRED handling" ``` --- ### Task 11: Frontend Passkey and Auth Policy Hooks **Files:** - Modify: `ui/src/api/tenant-hooks.ts` - Modify: `ui/src/api/vendor-hooks.ts` - [ ] **Step 1: Add passkey hooks to tenant-hooks.ts** Add the import for new types: ```typescript import type { ..., PasskeyCredential, AuthPolicy } from '../types/api'; ``` After the existing MFA hooks (after `useUpdateTenantSettings`), add: ```typescript // Passkey hooks export function usePasskeyList() { return useQuery({ queryKey: ['tenant', 'mfa', 'webauthn'], queryFn: () => api.get('/tenant/mfa/webauthn'), }); } export function useRenamePasskey() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, name }) => api.patch(`/tenant/mfa/webauthn/${id}/name`, { name }), onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), }); } export function useDeletePasskey() { const qc = useQueryClient(); return useMutation({ mutationFn: (id) => api.delete(`/tenant/mfa/webauthn/${id}`), onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), }); } export function useUpdateMfaMethodPreference() { return useMutation({ mutationFn: (preference) => api.post('/tenant/mfa/method-preference', { preference }), }); } // Auth settings hooks export function useTenantAuthSettings() { return useQuery({ queryKey: ['tenant', 'auth-settings'], queryFn: () => api.get('/tenant/auth-settings'), }); } export function useUpdateTenantAuthSettings() { const qc = useQueryClient(); return useMutation>({ mutationFn: (updates) => api.patch('/tenant/auth-settings', updates), onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'auth-settings'] }), }); } ``` - [ ] **Step 2: Add vendor auth policy hooks to vendor-hooks.ts** Add these hooks (import `AuthPolicy` from types): ```typescript import type { AuthPolicy } from '../types/api'; export function useVendorAuthPolicy() { return useQuery({ queryKey: ['vendor', 'auth-policy'], queryFn: () => api.get('/vendor/auth-policy'), }); } export function useUpdateVendorAuthPolicy() { const qc = useQueryClient(); return useMutation>({ mutationFn: (updates) => api.putJson('/vendor/auth-policy', updates), onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'auth-policy'] }), }); } ``` The existing `api.put` expects `FormData`. Add a `putJson` method to `client.ts`: ```typescript putJson: (path: string, body: unknown) => apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }), ``` - [ ] **Step 3: Verify build** Run: `cd ui && npm run build` Expected: No type errors - [ ] **Step 4: Commit** ```bash git add ui/src/api/tenant-hooks.ts ui/src/api/vendor-hooks.ts ui/src/api/client.ts git commit -m "feat: add passkey and auth policy React Query hooks" ``` --- ### Task 12: Vendor Auth Policy Page **Files:** - Create: `ui/src/pages/vendor/AuthPolicyPage.tsx` - Modify: `ui/src/router.tsx` - [ ] **Step 1: Create the vendor auth policy page** ```tsx import { useState } from 'react'; import { Card, Button, Badge, Alert } from '@cameleer/design-system'; import { useVendorAuthPolicy, useUpdateVendorAuthPolicy } from '../../api/vendor-hooks'; import { useToast } from '@cameleer/design-system'; import { errorMessage } from '../../api/client'; import styles from './AuthPolicyPage.module.css'; export function AuthPolicyPage() { const { data: policy, isLoading } = useVendorAuthPolicy(); const updatePolicy = useUpdateVendorAuthPolicy(); const { toast } = useToast(); const [confirmRequired, setConfirmRequired] = useState(false); if (isLoading || !policy) return null; async function handleMfaModeChange(mode: string) { if (mode === 'required' && policy?.mfaMode !== 'required') { setConfirmRequired(true); return; } try { await updatePolicy.mutateAsync({ mfaMode: mode }); toast({ title: `MFA mode set to ${mode}`, variant: 'success' }); } catch (err) { toast({ title: 'Failed to update policy', description: errorMessage(err), variant: 'error' }); } } async function handleConfirmRequired() { try { await updatePolicy.mutateAsync({ mfaMode: 'required' }); setConfirmRequired(false); toast({ title: 'MFA is now required for all tenant admins', variant: 'success' }); } catch (err) { toast({ title: 'Failed to update policy', description: errorMessage(err), variant: 'error' }); } } async function handlePasskeyToggle() { try { await updatePolicy.mutateAsync({ passkeyEnabled: !policy?.passkeyEnabled }); toast({ title: policy?.passkeyEnabled ? 'Passkeys disabled' : 'Passkeys enabled', variant: 'success', }); } catch (err) { toast({ title: 'Failed to update policy', description: errorMessage(err), variant: 'error' }); } } async function handlePasskeyModeChange(mode: string) { try { await updatePolicy.mutateAsync({ passkeyMode: mode }); toast({ title: `Passkey mode set to ${mode}`, variant: 'success' }); } catch (err) { toast({ title: 'Failed to update policy', description: errorMessage(err), variant: 'error' }); } } return (

Authentication Policy

Controls how tenant admins authenticate to the SaaS platform. This does not affect how tenant users access their dashboards — tenants set their own policy.

Require tenant admins to use MFA when accessing the management platform.

MFA Mode
{['off', 'optional', 'required'].map((mode) => ( ))}
{confirmRequired && (
All tenant admins who have not enrolled in MFA will be blocked from the platform until they enroll.
)}

Allow tenant admins to use passkeys (fingerprint, face, or security key) for authentication.

Passkeys
{policy.passkeyEnabled && (
Passkey Mode
{['optional', 'preferred', 'required'].map((mode) => ( ))}
)}
); } ``` - [ ] **Step 2: Create the CSS module** ```css /* AuthPolicyPage.module.css */ .subtitle { color: var(--text-muted); margin-bottom: 24px; } .description { color: var(--text-muted); font-size: 0.875rem; margin-top: 0; margin-bottom: 16px; } .controlRow { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; font-size: 0.875rem; } .buttonGroup { display: flex; gap: 8px; } ``` - [ ] **Step 3: Add route in router.tsx** Add a route for the auth policy page under the vendor routes: ```tsx import { AuthPolicyPage } from './pages/vendor/AuthPolicyPage'; ``` Add to the vendor route children: ```tsx { path: 'auth-policy', element: }, ``` - [ ] **Step 4: Add sidebar link in Layout.tsx** Add "Auth Policy" to the vendor sidebar section (after existing vendor links): ```tsx { to: '/vendor/auth-policy', label: 'Auth Policy' }, ``` - [ ] **Step 5: Verify build** Run: `cd ui && npm run build` Expected: No errors - [ ] **Step 6: Commit** ```bash git add ui/src/pages/vendor/AuthPolicyPage.tsx \ ui/src/pages/vendor/AuthPolicyPage.module.css \ ui/src/router.tsx \ ui/src/Layout.tsx git commit -m "feat: add vendor authentication policy management page" ``` --- ### Task 13: Passkey Management Section in Tenant Settings **Files:** - Modify: `ui/src/pages/tenant/SettingsPage.tsx` - [ ] **Step 1: Install @simplewebauthn/browser in both ui projects** Run: ```bash cd ui && npm install @simplewebauthn/browser cd ui/sign-in && npm install @simplewebauthn/browser ``` - [ ] **Step 2: Add PasskeySection component to SettingsPage.tsx** After the existing `MfaEnforcementToggle` component (after line 341), add: ```tsx function PasskeySection() { const { toast } = useToast(); const { data: status } = useMfaStatus(); const { data: passkeys, isLoading } = usePasskeyList(); const renamePasskey = useRenamePasskey(); const deletePasskey = useDeletePasskey(); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); const [confirmDeleteId, setConfirmDeleteId] = useState(null); function parseAgent(agent: string | null): string { if (!agent) return 'Unknown device'; if (agent.includes('Chrome')) return agent.includes('Windows') ? 'Chrome on Windows' : agent.includes('Mac') ? 'Chrome on macOS' : agent.includes('Android') ? 'Chrome on Android' : 'Chrome'; if (agent.includes('Safari') && !agent.includes('Chrome')) return agent.includes('iPhone') ? 'Safari on iPhone' : 'Safari on macOS'; if (agent.includes('Firefox')) return 'Firefox'; if (agent.includes('Edge')) return 'Edge'; return 'Browser'; } function startRename(id: string, currentName: string | null) { setEditingId(id); setEditName(currentName ?? ''); } async function handleRename(id: string) { try { await renamePasskey.mutateAsync({ id, name: editName }); setEditingId(null); toast({ title: 'Passkey renamed', variant: 'success' }); } catch (err) { toast({ title: 'Failed to rename passkey', description: errorMessage(err), variant: 'error' }); } } async function handleDelete(id: string) { try { await deletePasskey.mutateAsync(id); setConfirmDeleteId(null); toast({ title: 'Passkey removed', variant: 'success' }); } catch (err) { toast({ title: 'Failed to remove passkey', description: errorMessage(err), variant: 'error' }); } } if (isLoading) return null; const credentials = passkeys ?? []; return (

Use your fingerprint, face, or security key to sign in faster.

{credentials.length === 0 ? (

No passkeys registered. Passkeys can be registered during sign-in when prompted.

) : (
{credentials.map((pk) => (
{editingId === pk.id ? (
setEditName(e.target.value)} placeholder="Passkey name" style={{ maxWidth: 200 }} />
) : ( <>
{pk.name || 'Unnamed passkey'}
{parseAgent(pk.agent)} · Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'}
)}
{editingId !== pk.id && (
{confirmDeleteId === pk.id ? ( <> ) : ( )}
)}
))}
)}
); } ``` - [ ] **Step 3: Add AuthPolicySection component** After `PasskeySection`, add: ```tsx function AuthPolicySection() { const scopes = useScopes(); const { toast } = useToast(); const { data: authSettings } = useTenantAuthSettings(); const updateAuth = useUpdateTenantAuthSettings(); if (!scopes.has('tenant:manage') || !authSettings) return null; async function handleMfaModeChange(mode: string) { try { await updateAuth.mutateAsync({ mfaMode: mode }); toast({ title: `MFA mode set to ${mode}`, variant: 'success' }); } catch (err) { toast({ title: 'Failed to update', description: errorMessage(err), variant: 'error' }); } } async function handlePasskeyToggle() { try { await updateAuth.mutateAsync({ passkeyEnabled: !authSettings.passkeyEnabled }); toast({ title: authSettings.passkeyEnabled ? 'Passkeys disabled' : 'Passkeys enabled', variant: 'success' }); } catch (err) { toast({ title: 'Failed to update', description: errorMessage(err), variant: 'error' }); } } async function handlePasskeyModeChange(mode: string) { try { await updateAuth.mutateAsync({ passkeyMode: mode }); toast({ title: `Passkey mode set to ${mode}`, variant: 'success' }); } catch (err) { toast({ title: 'Failed to update', description: errorMessage(err), variant: 'error' }); } } return (

Configure MFA and passkey requirements for your organization's users.

MFA Mode
{['off', 'optional', 'required'].map((mode) => ( ))}
Passkeys
{authSettings.passkeyEnabled && (
Passkey Mode
{['optional', 'preferred', 'required'].map((mode) => ( ))}
)}
); } ``` - [ ] **Step 4: Add components to SettingsPage render** In the `SettingsPage` component's return JSX, add after the existing MFA sections: ```tsx ``` Replace the old `` with `` since it supersedes the simple toggle. - [ ] **Step 5: Add new hook imports** At the top of SettingsPage.tsx, add to the imports from tenant-hooks: ```typescript import { ..., usePasskeyList, useRenamePasskey, useDeletePasskey, useTenantAuthSettings, useUpdateTenantAuthSettings } from '../../api/tenant-hooks'; ``` - [ ] **Step 6: Verify build** Run: `cd ui && npm run build` Expected: No errors - [ ] **Step 7: Commit** ```bash git add ui/src/pages/tenant/SettingsPage.tsx ui/package.json ui/sign-in/package.json git commit -m "feat: add passkey management and auth policy sections to tenant settings" ``` --- ### Task 14: Sign-In UI — WebAuthn Experience API Functions **Files:** - Modify: `ui/sign-in/src/experience-api.ts` - [ ] **Step 1: Add WebAuthn functions** After the existing `submitMfa` function (line 275), add: ```typescript // --- WebAuthn MFA Verification --- export async function startWebAuthnAuth(): Promise> { const res = await request('POST', '/verification/web-authn/authentication'); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `Failed to start passkey authentication (${res.status})`); } const data = await res.json(); return data; } export async function verifyWebAuthnAuth(payload: Record): Promise { const res = await request('POST', '/verification/web-authn/authentication/verify', payload); if (!res.ok) { const err = await res.json().catch(() => ({})); if (res.status === 422) { throw new Error('Passkey verification failed. Please try again.'); } throw new Error(err.message || `Passkey verification failed (${res.status})`); } const data = await res.json(); return data.verificationId; } ``` - [ ] **Step 2: Commit** ```bash git add ui/sign-in/src/experience-api.ts git commit -m "feat: add WebAuthn Experience API functions to sign-in UI" ``` --- ### Task 15: Sign-In UI — WebAuthn and Method Picker Modes **Files:** - Modify: `ui/sign-in/src/SignInPage.tsx` - [ ] **Step 1: Extend the Mode type** Replace the Mode type (line 13): ```typescript type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker'; ``` - [ ] **Step 2: Import WebAuthn functions and @simplewebauthn/browser** Add to imports: ```typescript import { startAuthentication } from '@simplewebauthn/browser'; import { startWebAuthnAuth, verifyWebAuthnAuth } from './experience-api'; ``` - [ ] **Step 3: Add WebAuthn state and handler** In the component, after existing MFA state declarations, add: ```typescript const [webauthnError, setWebauthnError] = useState(''); const [webauthnLoading, setWebauthnLoading] = useState(false); ``` Add the WebAuthn verification handler: ```typescript async function handleWebAuthnVerify() { setWebauthnError(''); setWebauthnLoading(true); try { const options = await startWebAuthnAuth(); const credential = await startAuthentication({ optionsJSON: options as any }); const verificationId = await verifyWebAuthnAuth(credential); const redirectTo = await submitMfa(verificationId); window.location.replace(redirectTo); } catch (err) { setWebauthnError(err instanceof Error ? err.message : 'Passkey verification failed'); setWebauthnLoading(false); } } ``` - [ ] **Step 4: Update the MFA routing logic** When `MfaRequiredError` is thrown (around line 124-128), check for enrolled factors and route to the right mode. Replace the catch block: ```typescript } catch (err) { if (err instanceof MfaRequiredError) { // Read method preference from localStorage const pref = localStorage.getItem('mfa_method_preference'); if (pref === 'webauthn') { setMode('mfaWebauthn'); } else if (pref === 'totp') { setMode('mfaVerify'); } else { // No preference — default to method picker if available, else TOTP setMode('mfaMethodPicker'); } return; } // ... existing error handling } ``` - [ ] **Step 5: Add the mfaMethodPicker render block** In the render section, after the existing `mfaBackupCode` block, add: ```tsx {mode === 'mfaMethodPicker' && (

Verify your identity

Choose a verification method

)} ``` - [ ] **Step 6: Add the mfaWebauthn render block** ```tsx {mode === 'mfaWebauthn' && (

Passkey verification

Use your fingerprint, face, or security key

{webauthnError && }
)} ``` - [ ] **Step 7: Add "Use passkey instead" link to existing TOTP mode** In the existing `mfaVerify` render block, after the backup code link, add: ```tsx ``` - [ ] **Step 8: Save method preference on successful verification** In the existing `handleMfaVerify` (TOTP), after `window.location.replace(redirectTo)`, add before the redirect: ```typescript localStorage.setItem('mfa_method_preference', 'totp'); ``` In `handleWebAuthnVerify`, before the redirect: ```typescript localStorage.setItem('mfa_method_preference', 'webauthn'); ``` - [ ] **Step 9: Auto-trigger passkey on entering mfaWebauthn mode** Add a `useEffect` that triggers the passkey prompt automatically when the mode changes to `mfaWebauthn`: ```typescript useEffect(() => { if (mode === 'mfaWebauthn') { handleWebAuthnVerify(); } }, [mode]); ``` Note: Wrap `handleWebAuthnVerify` in `useCallback` or define it outside the effect scope to avoid lint warnings. - [ ] **Step 10: Verify build** Run: `cd ui/sign-in && npm run build` Expected: No errors - [ ] **Step 11: Commit** ```bash git add ui/sign-in/src/SignInPage.tsx git commit -m "feat: add WebAuthn and method picker modes to sign-in UI" ``` --- ### Task 16: Post-Sign-In Passkey Nudge **Files:** - Modify: `ui/src/pages/tenant/SettingsPage.tsx` (or a shared layout component) - [ ] **Step 1: Add nudge banner to SettingsPage** At the top of the `SettingsPage` component render, add a nudge banner that shows when the user has no passkeys and the URL has `?passkey=nudge`: ```tsx function PasskeyNudgeBanner() { const { data: status } = useMfaStatus(); const [dismissed, setDismissed] = useState(false); // Check if nudge was recently dismissed const lastDismissed = localStorage.getItem('passkey_nudge_dismissed'); const recentlyDismissed = lastDismissed && (Date.now() - Number(lastDismissed)) < 30 * 24 * 60 * 60 * 1000; if (dismissed || recentlyDismissed || !status || status.passkeyEnrolled) return null; function handleDismiss() { localStorage.setItem('passkey_nudge_dismissed', String(Date.now())); setDismissed(true); } return (

Use your fingerprint, face, or security key instead of typing a code every time.

); } ``` Add `` at the top of the `SettingsPage` return. - [ ] **Step 2: Commit** ```bash git add ui/src/pages/tenant/SettingsPage.tsx git commit -m "feat: add passkey enrollment nudge banner on settings page" ``` --- ### Task 17: Onboarding Wizard Passkey Step **Files:** - Modify: `ui/src/pages/OnboardingPage.tsx` - [ ] **Step 1: Add optional passkey step** After the existing tenant creation success (when the form succeeds and before redirect), add a passkey offer state: ```tsx const [showPasskeyOffer, setShowPasskeyOffer] = useState(false); ``` After the `POST /onboarding/tenant` succeeds (in the submit handler), instead of immediately redirecting, check if passkeys are enabled: ```tsx // After tenant creation succeeds: const config = await fetch('/platform/api/config').then(r => r.json()); if (config.vendorAuthPolicy?.passkeyEnabled) { setShowPasskeyOffer(true); } else { // Existing redirect logic await signIn(); navigate('/'); } ``` Add the passkey offer UI that shows when `showPasskeyOffer` is true: ```tsx {showPasskeyOffer && (

Secure your account

Add a passkey to sign in faster with your fingerprint, face, or security key.

)} ``` Note: Since passkey registration during onboarding requires a Logto interaction, and the user just completed sign-up, the "Set up later" button redirects to settings. Full registration during onboarding is deferred since the user needs to be fully signed in with an org-scoped token first. ```tsx async function handleSkipPasskey() { await signIn(); navigate('/'); } ``` - [ ] **Step 2: Commit** ```bash git add ui/src/pages/OnboardingPage.tsx git commit -m "feat: add passkey offer step to onboarding wizard" ``` --- ### Task 18: Verify Full Build and Integration **Files:** None (verification only) - [ ] **Step 1: Build backend** Run: `./mvnw compile -q` Expected: BUILD SUCCESS - [ ] **Step 2: Build frontend** Run: `cd ui && npm run build && cd ../ui/sign-in && npm run build` Expected: Both succeed with no errors - [ ] **Step 3: Start the application locally** Run: `docker compose up -d` (or local dev startup) Verify: - App starts without errors - `GET /platform/api/config` returns `vendorAuthPolicy` field - Vendor admin can access `/platform/vendor/auth-policy` - Tenant settings page shows Passkey section and Auth Policy section - [ ] **Step 4: Test vendor auth policy CRUD** ``` GET /platform/api/vendor/auth-policy → { mfaMode: "off", passkeyEnabled: false, passkeyMode: "optional" } PUT /platform/api/vendor/auth-policy { mfaMode: "required" } → 200 GET /platform/api/vendor/auth-policy → { mfaMode: "required", ... } ``` - [ ] **Step 5: Test tenant auth settings** ``` GET /platform/api/tenant/auth-settings → { mfaMode: "off", passkeyEnabled: false, passkeyMode: "optional" } PUT /platform/api/tenant/auth-settings { passkeyEnabled: true } → 200 GET /platform/api/tenant/{slug}/mfa-policy → includes passkeyEnabled: true ``` - [ ] **Step 6: Test passkey list endpoint** ``` GET /platform/api/tenant/mfa/webauthn → [] (empty list initially) ``` - [ ] **Step 7: Test sign-in MFA flow** 1. Enable MFA for tenant 2. Sign in → should show method picker (or TOTP depending on enrollment) 3. The "Use passkey" button should attempt WebAuthn authentication - [ ] **Step 8: Final commit if any fixes needed** ```bash git add -A git commit -m "fix: integration fixes for passkey MFA feature" ```