diff --git a/docs/superpowers/plans/2026-04-27-passkey-mfa.md b/docs/superpowers/plans/2026-04-27-passkey-mfa.md new file mode 100644 index 0000000..1cadb56 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-passkey-mfa.md @@ -0,0 +1,1953 @@ +# 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" +```