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>
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 forvendor_auth_policytablesrc/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyRepository.java— Spring Data JPA repositorysrc/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyController.java— REST endpoints for vendor auth policy (separate from existingVendorTenantController)src/main/resources/db/migration/V003__passkey_mfa_support.sql— Migration: createvendor_auth_policytable with seed row
Backend (modified files)
src/.../config/MfaEnforcementFilter.java— Expand route matching and policy lookupsrc/.../config/SecurityConfig.java— Add new exempt routessrc/.../config/PublicConfigController.java— Expose vendor auth policy in/api/configsrc/.../identity/LogtoManagementClient.java— Add WebAuthn credential CRUD + custom data methodssrc/.../portal/TenantPortalService.java— Add WebAuthn status/list methods, extend settings whitelistsrc/.../portal/TenantPortalController.java— Add WebAuthn endpoints, extend mfa-policy responsedocker/logto-bootstrap.sh— Update Custom JWT script
Frontend — Sign-in UI (modified files)
ui/sign-in/src/experience-api.ts— Add WebAuthn verification functionsui/sign-in/src/SignInPage.tsx— Add WebAuthn and method-picker modes
Frontend — Sign-in UI (new files)
ui/sign-in/package.json— Add@simplewebauthn/browserdependency
Frontend — Platform UI (modified files)
ui/src/types/api.ts— Add passkey typesui/src/api/tenant-hooks.ts— Add passkey hooksui/src/api/vendor-hooks.ts— Add vendor auth policy hooksui/src/api/client.ts— HandleAPP_PASSKEY_REQUIREDerror codeui/src/pages/tenant/SettingsPage.tsx— Add PasskeySection + AuthPolicySection componentsui/src/pages/OnboardingPage.tsx— Add optional passkey stepui/src/router.tsx— Add vendor auth policy route
Frontend — Platform UI (new files)
ui/src/pages/vendor/AuthPolicyPage.tsx— Vendor auth policy management pageui/src/pages/vendor/AuthPolicyPage.module.css— Styles for auth policy pageui/package.json— Add@simplewebauthn/browserdependency
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
Setimport 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)} · 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/configreturnsvendorAuthPolicyfield -
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
- Enable MFA for tenant
- Sign in → should show method picker (or TOTP depending on enrollment)
- 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"