Files
cameleer-saas/docs/superpowers/plans/2026-04-27-passkey-mfa.md
hsiegeln ca19faf4f0 docs: add passkey MFA implementation plan
18-task plan covering database migration, backend policy/endpoints,
sign-in UI WebAuthn modes, and platform UI management pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 08:39:44 +02:00

65 KiB

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

-- 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
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

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
package net.siegeln.cameleer.saas.vendor;

import org.springframework.data.jpa.repository.JpaRepository;

public interface VendorAuthPolicyRepository extends JpaRepository<VendorAuthPolicyEntity, Integer> {

    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
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

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<String> VALID_MFA_MODES = Set.of("off", "optional", "required");
    private static final Set<String> 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<AuthPolicyResponse> getPolicy() {
        return ResponseEntity.ok(AuthPolicyResponse.from(repository.getPolicy()));
    }

    @PutMapping
    public ResponseEntity<AuthPolicyResponse> 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
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:

    /** List WebAuthn credentials for a user (filtered from all MFA verifications). */
    @SuppressWarnings("unchecked")
    public List<Map<String, Object>> 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<String, Object> 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
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):

    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):

    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:

    // --- Passkey methods ---

    public record PasskeyCredential(String id, String name, String agent, String createdAt) {}

    @SuppressWarnings("unchecked")
    public List<PasskeyCredential> 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):

    public void updateTenantSettings(Map<String, Object> updates) {
        TenantEntity tenant = resolveTenant();
        Map<String, Object> 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:

    public record AuthSettingsData(String mfaMode, boolean passkeyEnabled, String passkeyMode) {}

    public AuthSettingsData getAuthSettings() {
        TenantEntity tenant = resolveTenant();
        Map<String, Object> 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:

import java.util.Set;
  • Step 7: Verify compilation

Run: ./mvnw compile -q Expected: BUILD SUCCESS

  • Step 8: Commit
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:

    // --- Passkey endpoints ---

    @GetMapping("/mfa/webauthn")
    public ResponseEntity<List<TenantPortalService.PasskeyCredential>> listPasskeys(
            @AuthenticationPrincipal Jwt jwt) {
        return ResponseEntity.ok(portalService.listPasskeys(jwt.getSubject()));
    }

    @PatchMapping("/mfa/webauthn/{id}/name")
    public ResponseEntity<Void> renamePasskey(@AuthenticationPrincipal Jwt jwt,
                                              @PathVariable String id,
                                              @RequestBody Map<String, String> 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<Void> 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<Void> updateMfaMethodPreference(@AuthenticationPrincipal Jwt jwt,
                                                           @RequestBody Map<String, String> 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:

    // --- Auth settings endpoints ---

    @GetMapping("/auth-settings")
    public ResponseEntity<TenantPortalService.AuthSettingsData> getAuthSettings() {
        return ResponseEntity.ok(portalService.getAuthSettings());
    }

    @PutMapping("/auth-settings")
    public ResponseEntity<Void> updateAuthSettings(@RequestBody Map<String, Object> updates) {
        portalService.updateTenantSettings(updates);
        return ResponseEntity.ok().build();
    }
  • Step 3: Extend the mfa-policy endpoint

Replace the existing getMfaPolicy method (lines 194-204):

    @GetMapping("/{slug}/mfa-policy")
    public ResponseEntity<Map<String, Object>> getMfaPolicy(@PathVariable String slug) {
        var tenantOpt = tenantService.getBySlug(slug);
        if (tenantOpt.isEmpty()) {
            return ResponseEntity.notFound().build();
        }
        var tenant = tenantOpt.get();
        Map<String, Object> 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
import java.util.List;
import java.util.Set;
  • Step 5: Verify compilation

Run: ./mvnw compile -q Expected: BUILD SUCCESS

  • Step 6: Commit
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:

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<String> 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<String, Object> 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
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:

    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):

        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:

import net.siegeln.cameleer.saas.vendor.VendorAuthPolicyRepository;
  • Step 2: Verify compilation

Run: ./mvnw compile -q Expected: BUILD SUCCESS

  • Step 3: Commit
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):

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
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:

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:

export interface MfaStatus {
  enrolled: boolean;
  hasBackupCodes: boolean;
  passkeyEnrolled: boolean;
  passkeyCount: number;
}

Update TenantSettings to include auth settings:

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):

    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
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:

import type { ..., PasskeyCredential, AuthPolicy } from '../types/api';

After the existing MFA hooks (after useUpdateTenantSettings), add:

// Passkey hooks
export function usePasskeyList() {
  return useQuery<PasskeyCredential[]>({
    queryKey: ['tenant', 'mfa', 'webauthn'],
    queryFn: () => api.get('/tenant/mfa/webauthn'),
  });
}

export function useRenamePasskey() {
  const qc = useQueryClient();
  return useMutation<void, Error, { id: string; name: string }>({
    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<void, Error, string>({
    mutationFn: (id) => api.delete(`/tenant/mfa/webauthn/${id}`),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
  });
}

export function useUpdateMfaMethodPreference() {
  return useMutation<void, Error, string>({
    mutationFn: (preference) => api.post('/tenant/mfa/method-preference', { preference }),
  });
}

// Auth settings hooks
export function useTenantAuthSettings() {
  return useQuery<AuthPolicy>({
    queryKey: ['tenant', 'auth-settings'],
    queryFn: () => api.get('/tenant/auth-settings'),
  });
}

export function useUpdateTenantAuthSettings() {
  const qc = useQueryClient();
  return useMutation<void, Error, Partial<AuthPolicy>>({
    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):

import type { AuthPolicy } from '../types/api';

export function useVendorAuthPolicy() {
  return useQuery<AuthPolicy>({
    queryKey: ['vendor', 'auth-policy'],
    queryFn: () => api.get('/vendor/auth-policy'),
  });
}

export function useUpdateVendorAuthPolicy() {
  const qc = useQueryClient();
  return useMutation<AuthPolicy, Error, Partial<AuthPolicy>>({
    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:

  putJson: <T>(path: string, body: unknown) =>
    apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
  • Step 3: Verify build

Run: cd ui && npm run build Expected: No type errors

  • Step 4: Commit
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

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 (
    <div>
      <h1>Authentication Policy</h1>
      <p className={styles.subtitle}>
        Controls how tenant admins authenticate to the SaaS platform. This does not affect how tenant users access their dashboards  tenants set their own policy.
      </p>

      <Card title="Multi-Factor Authentication">
        <p className={styles.description}>
          Require tenant admins to use MFA when accessing the management platform.
        </p>
        <div className={styles.controlRow}>
          <span>MFA Mode</span>
          <Badge label={policy.mfaMode} color={policy.mfaMode === 'required' ? 'success' : 'auto'} />
        </div>
        <div className={styles.buttonGroup}>
          {['off', 'optional', 'required'].map((mode) => (
            <Button
              key={mode}
              variant={policy.mfaMode === mode ? 'primary' : 'secondary'}
              onClick={() => handleMfaModeChange(mode)}
              loading={updatePolicy.isPending}
              size="sm"
            >
              {mode.charAt(0).toUpperCase() + mode.slice(1)}
            </Button>
          ))}
        </div>
        {confirmRequired && (
          <div style={{ marginTop: 12 }}>
            <Alert variant="warning" title="Confirm MFA requirement">
              All tenant admins who have not enrolled in MFA will be blocked from the platform until they enroll.
            </Alert>
            <div className={styles.buttonGroup} style={{ marginTop: 12 }}>
              <Button variant="primary" onClick={handleConfirmRequired} loading={updatePolicy.isPending}>
                Yes, require MFA
              </Button>
              <Button variant="secondary" onClick={() => setConfirmRequired(false)}>Cancel</Button>
            </div>
          </div>
        )}
      </Card>

      <Card title="Passkeys">
        <p className={styles.description}>
          Allow tenant admins to use passkeys (fingerprint, face, or security key) for authentication.
        </p>
        <div className={styles.controlRow}>
          <span>Passkeys</span>
          <Badge label={policy.passkeyEnabled ? 'Enabled' : 'Disabled'} color={policy.passkeyEnabled ? 'success' : 'auto'} />
        </div>
        <Button
          variant={policy.passkeyEnabled ? 'danger' : 'primary'}
          onClick={handlePasskeyToggle}
          loading={updatePolicy.isPending}
          size="sm"
        >
          {policy.passkeyEnabled ? 'Disable passkeys' : 'Enable passkeys'}
        </Button>

        {policy.passkeyEnabled && (
          <div style={{ marginTop: 16 }}>
            <div className={styles.controlRow}>
              <span>Passkey Mode</span>
              <Badge label={policy.passkeyMode} color={policy.passkeyMode === 'required' ? 'success' : 'auto'} />
            </div>
            <div className={styles.buttonGroup}>
              {['optional', 'preferred', 'required'].map((mode) => (
                <Button
                  key={mode}
                  variant={policy.passkeyMode === mode ? 'primary' : 'secondary'}
                  onClick={() => handlePasskeyModeChange(mode)}
                  loading={updatePolicy.isPending}
                  size="sm"
                >
                  {mode.charAt(0).toUpperCase() + mode.slice(1)}
                </Button>
              ))}
            </div>
          </div>
        )}
      </Card>
    </div>
  );
}
  • Step 2: Create the CSS module
/* 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:

import { AuthPolicyPage } from './pages/vendor/AuthPolicyPage';

Add to the vendor route children:

{ path: 'auth-policy', element: <AuthPolicyPage /> },
  • Step 4: Add sidebar link in Layout.tsx

Add "Auth Policy" to the vendor sidebar section (after existing vendor links):

{ to: '/vendor/auth-policy', label: 'Auth Policy' },
  • Step 5: Verify build

Run: cd ui && npm run build Expected: No errors

  • Step 6: Commit
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:

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:

function PasskeySection() {
  const { toast } = useToast();
  const { data: status } = useMfaStatus();
  const { data: passkeys, isLoading } = usePasskeyList();
  const renamePasskey = useRenamePasskey();
  const deletePasskey = useDeletePasskey();
  const [editingId, setEditingId] = useState<string | null>(null);
  const [editName, setEditName] = useState('');
  const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(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 (
    <Card title="Passkeys">
      <p className={styles.description} style={{ marginTop: 0 }}>
        Use your fingerprint, face, or security key to sign in faster.
      </p>

      {credentials.length === 0 ? (
        <p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
          No passkeys registered. Passkeys can be registered during sign-in when prompted.
        </p>
      ) : (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          {credentials.map((pk) => (
            <div key={pk.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
              <div style={{ flex: 1 }}>
                {editingId === pk.id ? (
                  <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
                    <Input
                      value={editName}
                      onChange={(e) => setEditName(e.target.value)}
                      placeholder="Passkey name"
                      style={{ maxWidth: 200 }}
                    />
                    <Button size="sm" variant="primary" onClick={() => handleRename(pk.id)} loading={renamePasskey.isPending}>Save</Button>
                    <Button size="sm" variant="secondary" onClick={() => setEditingId(null)}>Cancel</Button>
                  </div>
                ) : (
                  <>
                    <div style={{ fontWeight: 500 }}>{pk.name || 'Unnamed passkey'}</div>
                    <div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
                      {parseAgent(pk.agent)} &middot; Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'unknown'}
                    </div>
                  </>
                )}
              </div>
              {editingId !== pk.id && (
                <div style={{ display: 'flex', gap: 8 }}>
                  <Button size="sm" variant="secondary" onClick={() => startRename(pk.id, pk.name)}>Rename</Button>
                  {confirmDeleteId === pk.id ? (
                    <>
                      <Button size="sm" variant="danger" onClick={() => handleDelete(pk.id)} loading={deletePasskey.isPending}>Confirm</Button>
                      <Button size="sm" variant="secondary" onClick={() => setConfirmDeleteId(null)}>Cancel</Button>
                    </>
                  ) : (
                    <Button size="sm" variant="danger" onClick={() => setConfirmDeleteId(pk.id)}>Remove</Button>
                  )}
                </div>
              )}
            </div>
          ))}
        </div>
      )}
    </Card>
  );
}
  • Step 3: Add AuthPolicySection component

After PasskeySection, add:

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 (
    <Card title="Authentication Policy">
      <p className={styles.description} style={{ marginTop: 0 }}>
        Configure MFA and passkey requirements for your organization's users.
      </p>

      <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
        <span style={{ fontSize: '0.875rem' }}>MFA Mode</span>
        <Badge label={authSettings.mfaMode} color={authSettings.mfaMode === 'required' ? 'success' : 'auto'} />
      </div>
      <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
        {['off', 'optional', 'required'].map((mode) => (
          <Button key={mode} variant={authSettings.mfaMode === mode ? 'primary' : 'secondary'}
                  onClick={() => handleMfaModeChange(mode)} loading={updateAuth.isPending} size="sm">
            {mode.charAt(0).toUpperCase() + mode.slice(1)}
          </Button>
        ))}
      </div>

      <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
        <span style={{ fontSize: '0.875rem' }}>Passkeys</span>
        <Badge label={authSettings.passkeyEnabled ? 'Enabled' : 'Disabled'} color={authSettings.passkeyEnabled ? 'success' : 'auto'} />
      </div>
      <Button variant={authSettings.passkeyEnabled ? 'danger' : 'primary'}
              onClick={handlePasskeyToggle} loading={updateAuth.isPending} size="sm">
        {authSettings.passkeyEnabled ? 'Disable passkeys' : 'Enable passkeys'}
      </Button>

      {authSettings.passkeyEnabled && (
        <div style={{ marginTop: 16 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
            <span style={{ fontSize: '0.875rem' }}>Passkey Mode</span>
            <Badge label={authSettings.passkeyMode} color={authSettings.passkeyMode === 'required' ? 'success' : 'auto'} />
          </div>
          <div style={{ display: 'flex', gap: 8 }}>
            {['optional', 'preferred', 'required'].map((mode) => (
              <Button key={mode} variant={authSettings.passkeyMode === mode ? 'primary' : 'secondary'}
                      onClick={() => handlePasskeyModeChange(mode)} loading={updateAuth.isPending} size="sm">
                {mode.charAt(0).toUpperCase() + mode.slice(1)}
              </Button>
            ))}
          </div>
        </div>
      )}
    </Card>
  );
}
  • Step 4: Add components to SettingsPage render

In the SettingsPage component's return JSX, add after the existing MFA sections:

<PasskeySection />
<AuthPolicySection />

Replace the old <MfaEnforcementToggle /> with <AuthPolicySection /> 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:

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
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:

// --- WebAuthn MFA Verification ---

export async function startWebAuthnAuth(): Promise<Record<string, unknown>> {
  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<string, unknown>): Promise<string> {
  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
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):

type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker';
  • Step 2: Import WebAuthn functions and @simplewebauthn/browser

Add to imports:

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:

const [webauthnError, setWebauthnError] = useState('');
const [webauthnLoading, setWebauthnLoading] = useState(false);

Add the WebAuthn verification handler:

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:

} 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:

{mode === 'mfaMethodPicker' && (
  <Card>
    <div style={{ textAlign: 'center', marginBottom: 16 }}>
      <h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Verify your identity</h2>
      <p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>Choose a verification method</p>
    </div>
    <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
      <Button variant="primary" onClick={() => { setMode('mfaWebauthn'); }}>
        Use passkey
      </Button>
      <Button variant="secondary" onClick={() => setMode('mfaVerify')}>
        Use authenticator code
      </Button>
    </div>
  </Card>
)}
  • Step 6: Add the mfaWebauthn render block
{mode === 'mfaWebauthn' && (
  <Card>
    <div style={{ textAlign: 'center', marginBottom: 16 }}>
      <h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Passkey verification</h2>
      <p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
        Use your fingerprint, face, or security key
      </p>
    </div>
    {webauthnError && <Alert variant="error" title={webauthnError} />}
    <Button variant="primary" onClick={handleWebAuthnVerify} loading={webauthnLoading} style={{ width: '100%' }}>
      Verify with passkey
    </Button>
    <div style={{ textAlign: 'center', marginTop: 16 }}>
      <button type="button" className={linkClass} onClick={() => setMode('mfaVerify')}>
        Use authenticator code instead
      </button>
    </div>
  </Card>
)}
  • Step 7: Add "Use passkey instead" link to existing TOTP mode

In the existing mfaVerify render block, after the backup code link, add:

<button type="button" className={linkClass} onClick={() => setMode('mfaWebauthn')}>
  Use passkey instead
</button>
  • Step 8: Save method preference on successful verification

In the existing handleMfaVerify (TOTP), after window.location.replace(redirectTo), add before the redirect:

localStorage.setItem('mfa_method_preference', 'totp');

In handleWebAuthnVerify, before the redirect:

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:

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
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:

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 (
    <Alert variant="info" title="Sign in faster with a passkey">
      <p style={{ margin: '4px 0 12px' }}>
        Use your fingerprint, face, or security key instead of typing a code every time.
      </p>
      <div style={{ display: 'flex', gap: 8 }}>
        <Button size="sm" variant="secondary" onClick={handleDismiss}>Not now</Button>
      </div>
    </Alert>
  );
}

Add <PasskeyNudgeBanner /> at the top of the SettingsPage return.

  • Step 2: Commit
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:

const [showPasskeyOffer, setShowPasskeyOffer] = useState(false);

After the POST /onboarding/tenant succeeds (in the submit handler), instead of immediately redirecting, check if passkeys are enabled:

// 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:

{showPasskeyOffer && (
  <Card>
    <div style={{ textAlign: 'center' }}>
      <Logo />
      <h2 style={{ margin: '16px 0 8px' }}>Secure your account</h2>
      <p style={{ color: 'var(--text-muted)', marginBottom: 24 }}>
        Add a passkey to sign in faster with your fingerprint, face, or security key.
      </p>
    </div>
    <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
      <Button variant="primary" onClick={handleSkipPasskey}>
        Set up later
      </Button>
      <Button variant="secondary" onClick={handleSkipPasskey}>
        Skip for now
      </Button>
    </div>
  </Card>
)}

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.

async function handleSkipPasskey() {
  await signIn();
  navigate('/');
}
  • Step 2: Commit
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
git add -A
git commit -m "fix: integration fixes for passkey MFA feature"