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

1954 lines
65 KiB
Markdown

# Passkey MFA Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add passkeys (WebAuthn) as an MFA factor alongside existing TOTP, with hierarchical auth policy enforcement and rich device management.
**Architecture:** Logto-native WebAuthn via Experience API (sign-in) and Management API (settings-page enrollment). Two independent policy domains — vendor controls platform logins, tenant controls org user logins. All credential storage stays in Logto; SaaS backend adds policy enforcement and exposes Logto data through its own API.
**Tech Stack:** Spring Boot 3, Logto Experience API + Management API, React 19, @tanstack/react-query, @simplewebauthn/browser, @cameleer/design-system
---
## File Map
### Backend (new files)
- `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyEntity.java` — JPA entity for `vendor_auth_policy` table
- `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyRepository.java` — Spring Data JPA repository
- `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyController.java` — REST endpoints for vendor auth policy (separate from existing `VendorTenantController`)
- `src/main/resources/db/migration/V003__passkey_mfa_support.sql` — Migration: create `vendor_auth_policy` table with seed row
### Backend (modified files)
- `src/.../config/MfaEnforcementFilter.java` — Expand route matching and policy lookup
- `src/.../config/SecurityConfig.java` — Add new exempt routes
- `src/.../config/PublicConfigController.java` — Expose vendor auth policy in `/api/config`
- `src/.../identity/LogtoManagementClient.java` — Add WebAuthn credential CRUD + custom data methods
- `src/.../portal/TenantPortalService.java` — Add WebAuthn status/list methods, extend settings whitelist
- `src/.../portal/TenantPortalController.java` — Add WebAuthn endpoints, extend mfa-policy response
- `docker/logto-bootstrap.sh` — Update Custom JWT script
### Frontend — Sign-in UI (modified files)
- `ui/sign-in/src/experience-api.ts` — Add WebAuthn verification functions
- `ui/sign-in/src/SignInPage.tsx` — Add WebAuthn and method-picker modes
### Frontend — Sign-in UI (new files)
- `ui/sign-in/package.json` — Add `@simplewebauthn/browser` dependency
### Frontend — Platform UI (modified files)
- `ui/src/types/api.ts` — Add passkey types
- `ui/src/api/tenant-hooks.ts` — Add passkey hooks
- `ui/src/api/vendor-hooks.ts` — Add vendor auth policy hooks
- `ui/src/api/client.ts` — Handle `APP_PASSKEY_REQUIRED` error code
- `ui/src/pages/tenant/SettingsPage.tsx` — Add PasskeySection + AuthPolicySection components
- `ui/src/pages/OnboardingPage.tsx` — Add optional passkey step
- `ui/src/router.tsx` — Add vendor auth policy route
### Frontend — Platform UI (new files)
- `ui/src/pages/vendor/AuthPolicyPage.tsx` — Vendor auth policy management page
- `ui/src/pages/vendor/AuthPolicyPage.module.css` — Styles for auth policy page
- `ui/package.json` — Add `@simplewebauthn/browser` dependency
### Important: Passkey Registration Limitation
Passkey **registration** (creating a new credential) can only happen during a Logto Experience API interaction — i.e., during sign-in or when Logto prompts for MFA binding. The Logto Management API's `POST /api/users/{userId}/mfa-verifications` with `type: "WebAuthn"` requires browser-side WebAuthn ceremony that must be initiated through the Experience API, not the Management API.
This means:
- The **settings page** can list, rename, and delete passkeys but **cannot register new ones**
- New passkey registration happens during **sign-in** when Logto offers MFA binding
- The **post-sign-in nudge** and **onboarding** route users to the next sign-in where Logto will offer WebAuthn binding
- This is consistent with Approach A — we work within what Logto provides
---
### Task 1: Database Migration — vendor_auth_policy Table
**Files:**
- Create: `src/main/resources/db/migration/V003__passkey_mfa_support.sql`
- [ ] **Step 1: Write the migration SQL**
```sql
-- V003__passkey_mfa_support.sql
-- Adds vendor-level auth policy table for platform login enforcement
CREATE TABLE IF NOT EXISTS vendor_auth_policy (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
mfa_mode VARCHAR(10) NOT NULL DEFAULT 'off',
passkey_enabled BOOLEAN NOT NULL DEFAULT false,
passkey_mode VARCHAR(10) NOT NULL DEFAULT 'optional',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Seed with default (no enforcement)
INSERT INTO vendor_auth_policy (id) VALUES (1) ON CONFLICT DO NOTHING;
```
- [ ] **Step 2: Verify migration runs**
Run: `./mvnw flyway:info -q` (or start the app and check logs for `V003__passkey_mfa_support`)
Expected: V003 listed as pending or applied
- [ ] **Step 3: Commit**
```bash
git add src/main/resources/db/migration/V003__passkey_mfa_support.sql
git commit -m "feat: add vendor_auth_policy table for passkey MFA support"
```
---
### Task 2: VendorAuthPolicy Entity and Repository
**Files:**
- Create: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyEntity.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyRepository.java`
- [ ] **Step 1: Create the JPA entity**
```java
package net.siegeln.cameleer.saas.vendor;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "vendor_auth_policy")
public class VendorAuthPolicyEntity {
@Id
@Column(name = "id")
private Integer id = 1;
@Column(name = "mfa_mode", nullable = false)
private String mfaMode = "off";
@Column(name = "passkey_enabled", nullable = false)
private boolean passkeyEnabled = false;
@Column(name = "passkey_mode", nullable = false)
private String passkeyMode = "optional";
@Column(name = "updated_at", nullable = false)
private Instant updatedAt = Instant.now();
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
// Getters and setters
public Integer getId() { return id; }
public String getMfaMode() { return mfaMode; }
public void setMfaMode(String mfaMode) { this.mfaMode = mfaMode; }
public boolean isPasskeyEnabled() { return passkeyEnabled; }
public void setPasskeyEnabled(boolean passkeyEnabled) { this.passkeyEnabled = passkeyEnabled; }
public String getPasskeyMode() { return passkeyMode; }
public void setPasskeyMode(String passkeyMode) { this.passkeyMode = passkeyMode; }
public Instant getUpdatedAt() { return updatedAt; }
}
```
- [ ] **Step 2: Create the repository**
```java
package net.siegeln.cameleer.saas.vendor;
import org.springframework.data.jpa.repository.JpaRepository;
public interface VendorAuthPolicyRepository extends JpaRepository<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**
```bash
git add src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyEntity.java \
src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyRepository.java
git commit -m "feat: add VendorAuthPolicy entity and repository"
```
---
### Task 3: VendorAuthPolicyController
**Files:**
- Create: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyController.java`
- [ ] **Step 1: Create the controller**
```java
package net.siegeln.cameleer.saas.vendor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/api/vendor/auth-policy")
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
public class VendorAuthPolicyController {
private static final Set<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**
```bash
git add src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyController.java
git commit -m "feat: add vendor auth policy REST endpoints"
```
---
### Task 4: Extend LogtoManagementClient with WebAuthn Methods
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java`
- [ ] **Step 1: Add WebAuthn credential methods**
After the existing `deleteAllMfaVerifications` method (line 605), add:
```java
/** List WebAuthn credentials for a user (filtered from all MFA verifications). */
@SuppressWarnings("unchecked")
public List<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**
```bash
git add src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java
git commit -m "feat: add WebAuthn credential and custom data methods to LogtoManagementClient"
```
---
### Task 5: Extend TenantPortalService with Passkey Methods and Auth Settings
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java`
- [ ] **Step 1: Add passkey status to MfaStatusData**
Replace the existing `MfaStatusData` record (around line 80):
```java
public record MfaStatusData(boolean enrolled, boolean hasBackupCodes, boolean passkeyEnrolled, int passkeyCount) {}
```
- [ ] **Step 2: Update getMfaStatus to include passkey info**
Replace the existing `getMfaStatus` method (lines 294-301):
```java
public MfaStatusData getMfaStatus(String userId) {
var verifications = logtoClient.getUserMfaVerifications(userId);
boolean enrolled = verifications.stream()
.anyMatch(v -> "Totp".equals(String.valueOf(v.get("type"))));
boolean hasBackupCodes = verifications.stream()
.anyMatch(v -> "BackupCode".equals(String.valueOf(v.get("type"))));
long passkeyCount = verifications.stream()
.filter(v -> "WebAuthn".equals(String.valueOf(v.get("type"))))
.count();
return new MfaStatusData(enrolled, hasBackupCodes, passkeyCount > 0, (int) passkeyCount);
}
```
- [ ] **Step 3: Add passkey credential list/rename/delete methods**
After the `resetTeamMemberMfa` method (after line 371), add:
```java
// --- Passkey methods ---
public record PasskeyCredential(String id, String name, String agent, String createdAt) {}
@SuppressWarnings("unchecked")
public List<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):
```java
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:
```java
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:
```java
import java.util.Set;
```
- [ ] **Step 7: Verify compilation**
Run: `./mvnw compile -q`
Expected: BUILD SUCCESS
- [ ] **Step 8: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java
git commit -m "feat: add passkey management and auth settings to TenantPortalService"
```
---
### Task 6: Extend TenantPortalController with Passkey and Auth Settings Endpoints
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java`
- [ ] **Step 1: Add passkey management endpoints**
After the existing `resetTeamMemberMfa` endpoint (after line 186), add:
```java
// --- Passkey endpoints ---
@GetMapping("/mfa/webauthn")
public ResponseEntity<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:
```java
// --- 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):
```java
@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**
```java
import java.util.List;
import java.util.Set;
```
- [ ] **Step 5: Verify compilation**
Run: `./mvnw compile -q`
Expected: BUILD SUCCESS
- [ ] **Step 6: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java
git commit -m "feat: add passkey and auth settings endpoints to TenantPortalController"
```
---
### Task 7: Expand MfaEnforcementFilter for Vendor Policy and Passkey Checks
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java`
- [ ] **Step 1: Rewrite the filter**
Replace the entire file contents:
```java
package net.siegeln.cameleer.saas.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.siegeln.cameleer.saas.tenant.TenantService;
import net.siegeln.cameleer.saas.vendor.VendorAuthPolicyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
@Component
public class MfaEnforcementFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(MfaEnforcementFilter.class);
private static final Set<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**
```bash
git add src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java
git commit -m "feat: expand MfaEnforcementFilter for vendor policy and passkey checks"
```
---
### Task 8: Extend PublicConfigController with Vendor Auth Policy
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/config/PublicConfigController.java`
- [ ] **Step 1: Inject VendorAuthPolicyRepository and extend config response**
Add the repository field and update the `config()` method:
```java
private final VendorAuthPolicyRepository vendorPolicyRepo;
public PublicConfigController(VendorAuthPolicyRepository vendorPolicyRepo) {
this.vendorPolicyRepo = vendorPolicyRepo;
}
```
Replace the `config()` method's return statement (the `return Map.of(...)` at line 64):
```java
var policy = vendorPolicyRepo.getPolicy();
var vendorAuthPolicy = Map.of(
"mfaMode", policy.getMfaMode(),
"passkeyEnabled", policy.isPasskeyEnabled(),
"passkeyMode", policy.getPasskeyMode()
);
return Map.of(
"logtoEndpoint", endpoint,
"logtoClientId", clientId != null ? clientId : "",
"logtoResource", apiResource,
"scopes", SCOPES,
"vendorAuthPolicy", vendorAuthPolicy
);
```
Add the import:
```java
import net.siegeln.cameleer.saas.vendor.VendorAuthPolicyRepository;
```
- [ ] **Step 2: Verify compilation**
Run: `./mvnw compile -q`
Expected: BUILD SUCCESS
- [ ] **Step 3: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/config/PublicConfigController.java
git commit -m "feat: expose vendor auth policy in public config endpoint"
```
---
### Task 9: Update Custom JWT Script in Bootstrap
**Files:**
- Modify: `docker/logto-bootstrap.sh`
- [ ] **Step 1: Update the Custom JWT script**
Replace the `CUSTOM_JWT_SCRIPT` variable (lines 541-561):
```bash
CUSTOM_JWT_SCRIPT='const getCustomJwtClaims = async ({ token, context, environmentVariables }) => {
const roleMap = { owner: "server:admin", operator: "server:operator", viewer: "server:viewer" };
const roles = new Set();
if (context?.user?.organizationRoles) {
for (const orgRole of context.user.organizationRoles) {
const mapped = roleMap[orgRole.roleName];
if (mapped) roles.add(mapped);
}
}
if (context?.user?.roles) {
for (const role of context.user.roles) {
if (role.name === "saas-vendor") roles.add("server:admin");
}
}
const mfaFactors = context?.user?.mfaVerificationFactors || [];
const mfaEnrolled = mfaFactors.some(f => f.type === "Totp" || f.type === "WebAuthn");
const passkeyEnrolled = mfaFactors.some(f => f.type === "WebAuthn");
const claims = {};
if (roles.size > 0) claims.roles = [...roles];
claims.mfa_enrolled = mfaEnrolled;
claims.passkey_enrolled = passkeyEnrolled;
claims.mfa_method_preference = context?.user?.customData?.mfa_method_preference || null;
return claims;
};'
```
- [ ] **Step 2: Commit**
```bash
git add docker/logto-bootstrap.sh
git commit -m "feat: add passkey_enrolled and mfa_method_preference to Custom JWT claims"
```
---
### Task 10: Frontend Types and API Client Updates
**Files:**
- Modify: `ui/src/types/api.ts`
- Modify: `ui/src/api/client.ts`
- [ ] **Step 1: Add passkey types to api.ts**
After the existing `BackupCodesResponse` interface (line 257), add:
```typescript
export interface PasskeyCredential {
id: string;
name: string | null;
agent: string | null;
createdAt: string | null;
}
export interface AuthPolicy {
mfaMode: string;
passkeyEnabled: boolean;
passkeyMode: string;
}
```
Update `MfaStatus` to include passkey info:
```typescript
export interface MfaStatus {
enrolled: boolean;
hasBackupCodes: boolean;
passkeyEnrolled: boolean;
passkeyCount: number;
}
```
Update `TenantSettings` to include auth settings:
```typescript
export interface TenantSettings {
name: string;
slug: string;
tier: string;
status: string;
serverEndpoint: string | null;
createdAt: string;
mfaRequired?: boolean;
mfaMode?: string;
passkeyEnabled?: boolean;
passkeyMode?: string;
}
```
- [ ] **Step 2: Handle APP_PASSKEY_REQUIRED in client.ts**
In `apiFetch`, extend the 403 handling (after the `APP_MFA_REQUIRED` block at line 68-71):
```typescript
if (errorHeader === 'APP_PASSKEY_REQUIRED') {
window.location.href = '/platform/tenant/settings?passkey=required';
throw new ApiError(403, '{"message":"Passkey enrollment required"}');
}
```
- [ ] **Step 3: Verify build**
Run: `cd ui && npm run build`
Expected: No type errors
- [ ] **Step 4: Commit**
```bash
git add ui/src/types/api.ts ui/src/api/client.ts
git commit -m "feat: add passkey types and APP_PASSKEY_REQUIRED handling"
```
---
### Task 11: Frontend Passkey and Auth Policy Hooks
**Files:**
- Modify: `ui/src/api/tenant-hooks.ts`
- Modify: `ui/src/api/vendor-hooks.ts`
- [ ] **Step 1: Add passkey hooks to tenant-hooks.ts**
Add the import for new types:
```typescript
import type { ..., PasskeyCredential, AuthPolicy } from '../types/api';
```
After the existing MFA hooks (after `useUpdateTenantSettings`), add:
```typescript
// Passkey hooks
export function usePasskeyList() {
return useQuery<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):
```typescript
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`:
```typescript
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**
```bash
git add ui/src/api/tenant-hooks.ts ui/src/api/vendor-hooks.ts ui/src/api/client.ts
git commit -m "feat: add passkey and auth policy React Query hooks"
```
---
### Task 12: Vendor Auth Policy Page
**Files:**
- Create: `ui/src/pages/vendor/AuthPolicyPage.tsx`
- Modify: `ui/src/router.tsx`
- [ ] **Step 1: Create the vendor auth policy page**
```tsx
import { useState } from 'react';
import { Card, Button, Badge, Alert } from '@cameleer/design-system';
import { useVendorAuthPolicy, useUpdateVendorAuthPolicy } from '../../api/vendor-hooks';
import { useToast } from '@cameleer/design-system';
import { errorMessage } from '../../api/client';
import styles from './AuthPolicyPage.module.css';
export function AuthPolicyPage() {
const { data: policy, isLoading } = useVendorAuthPolicy();
const updatePolicy = useUpdateVendorAuthPolicy();
const { toast } = useToast();
const [confirmRequired, setConfirmRequired] = useState(false);
if (isLoading || !policy) return null;
async function handleMfaModeChange(mode: string) {
if (mode === 'required' && policy?.mfaMode !== 'required') {
setConfirmRequired(true);
return;
}
try {
await updatePolicy.mutateAsync({ mfaMode: mode });
toast({ title: `MFA mode set to ${mode}`, variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update policy', description: errorMessage(err), variant: 'error' });
}
}
async function handleConfirmRequired() {
try {
await updatePolicy.mutateAsync({ mfaMode: 'required' });
setConfirmRequired(false);
toast({ title: 'MFA is now required for all tenant admins', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update policy', description: errorMessage(err), variant: 'error' });
}
}
async function handlePasskeyToggle() {
try {
await updatePolicy.mutateAsync({ passkeyEnabled: !policy?.passkeyEnabled });
toast({
title: policy?.passkeyEnabled ? 'Passkeys disabled' : 'Passkeys enabled',
variant: 'success',
});
} catch (err) {
toast({ title: 'Failed to update policy', description: errorMessage(err), variant: 'error' });
}
}
async function handlePasskeyModeChange(mode: string) {
try {
await updatePolicy.mutateAsync({ passkeyMode: mode });
toast({ title: `Passkey mode set to ${mode}`, variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update policy', description: errorMessage(err), variant: 'error' });
}
}
return (
<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**
```css
/* AuthPolicyPage.module.css */
.subtitle {
color: var(--text-muted);
margin-bottom: 24px;
}
.description {
color: var(--text-muted);
font-size: 0.875rem;
margin-top: 0;
margin-bottom: 16px;
}
.controlRow {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
font-size: 0.875rem;
}
.buttonGroup {
display: flex;
gap: 8px;
}
```
- [ ] **Step 3: Add route in router.tsx**
Add a route for the auth policy page under the vendor routes:
```tsx
import { AuthPolicyPage } from './pages/vendor/AuthPolicyPage';
```
Add to the vendor route children:
```tsx
{ path: 'auth-policy', element: <AuthPolicyPage /> },
```
- [ ] **Step 4: Add sidebar link in Layout.tsx**
Add "Auth Policy" to the vendor sidebar section (after existing vendor links):
```tsx
{ to: '/vendor/auth-policy', label: 'Auth Policy' },
```
- [ ] **Step 5: Verify build**
Run: `cd ui && npm run build`
Expected: No errors
- [ ] **Step 6: Commit**
```bash
git add ui/src/pages/vendor/AuthPolicyPage.tsx \
ui/src/pages/vendor/AuthPolicyPage.module.css \
ui/src/router.tsx \
ui/src/Layout.tsx
git commit -m "feat: add vendor authentication policy management page"
```
---
### Task 13: Passkey Management Section in Tenant Settings
**Files:**
- Modify: `ui/src/pages/tenant/SettingsPage.tsx`
- [ ] **Step 1: Install @simplewebauthn/browser in both ui projects**
Run:
```bash
cd ui && npm install @simplewebauthn/browser
cd ui/sign-in && npm install @simplewebauthn/browser
```
- [ ] **Step 2: Add PasskeySection component to SettingsPage.tsx**
After the existing `MfaEnforcementToggle` component (after line 341), add:
```tsx
function PasskeySection() {
const { toast } = useToast();
const { data: status } = useMfaStatus();
const { data: passkeys, isLoading } = usePasskeyList();
const renamePasskey = useRenamePasskey();
const deletePasskey = useDeletePasskey();
const [editingId, setEditingId] = useState<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:
```tsx
function AuthPolicySection() {
const scopes = useScopes();
const { toast } = useToast();
const { data: authSettings } = useTenantAuthSettings();
const updateAuth = useUpdateTenantAuthSettings();
if (!scopes.has('tenant:manage') || !authSettings) return null;
async function handleMfaModeChange(mode: string) {
try {
await updateAuth.mutateAsync({ mfaMode: mode });
toast({ title: `MFA mode set to ${mode}`, variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update', description: errorMessage(err), variant: 'error' });
}
}
async function handlePasskeyToggle() {
try {
await updateAuth.mutateAsync({ passkeyEnabled: !authSettings.passkeyEnabled });
toast({ title: authSettings.passkeyEnabled ? 'Passkeys disabled' : 'Passkeys enabled', variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update', description: errorMessage(err), variant: 'error' });
}
}
async function handlePasskeyModeChange(mode: string) {
try {
await updateAuth.mutateAsync({ passkeyMode: mode });
toast({ title: `Passkey mode set to ${mode}`, variant: 'success' });
} catch (err) {
toast({ title: 'Failed to update', description: errorMessage(err), variant: 'error' });
}
}
return (
<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:
```tsx
<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:
```typescript
import { ..., usePasskeyList, useRenamePasskey, useDeletePasskey, useTenantAuthSettings, useUpdateTenantAuthSettings } from '../../api/tenant-hooks';
```
- [ ] **Step 6: Verify build**
Run: `cd ui && npm run build`
Expected: No errors
- [ ] **Step 7: Commit**
```bash
git add ui/src/pages/tenant/SettingsPage.tsx ui/package.json ui/sign-in/package.json
git commit -m "feat: add passkey management and auth policy sections to tenant settings"
```
---
### Task 14: Sign-In UI — WebAuthn Experience API Functions
**Files:**
- Modify: `ui/sign-in/src/experience-api.ts`
- [ ] **Step 1: Add WebAuthn functions**
After the existing `submitMfa` function (line 275), add:
```typescript
// --- WebAuthn MFA Verification ---
export async function startWebAuthnAuth(): Promise<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**
```bash
git add ui/sign-in/src/experience-api.ts
git commit -m "feat: add WebAuthn Experience API functions to sign-in UI"
```
---
### Task 15: Sign-In UI — WebAuthn and Method Picker Modes
**Files:**
- Modify: `ui/sign-in/src/SignInPage.tsx`
- [ ] **Step 1: Extend the Mode type**
Replace the Mode type (line 13):
```typescript
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker';
```
- [ ] **Step 2: Import WebAuthn functions and @simplewebauthn/browser**
Add to imports:
```typescript
import { startAuthentication } from '@simplewebauthn/browser';
import { startWebAuthnAuth, verifyWebAuthnAuth } from './experience-api';
```
- [ ] **Step 3: Add WebAuthn state and handler**
In the component, after existing MFA state declarations, add:
```typescript
const [webauthnError, setWebauthnError] = useState('');
const [webauthnLoading, setWebauthnLoading] = useState(false);
```
Add the WebAuthn verification handler:
```typescript
async function handleWebAuthnVerify() {
setWebauthnError('');
setWebauthnLoading(true);
try {
const options = await startWebAuthnAuth();
const credential = await startAuthentication({ optionsJSON: options as any });
const verificationId = await verifyWebAuthnAuth(credential);
const redirectTo = await submitMfa(verificationId);
window.location.replace(redirectTo);
} catch (err) {
setWebauthnError(err instanceof Error ? err.message : 'Passkey verification failed');
setWebauthnLoading(false);
}
}
```
- [ ] **Step 4: Update the MFA routing logic**
When `MfaRequiredError` is thrown (around line 124-128), check for enrolled factors and route to the right mode. Replace the catch block:
```typescript
} catch (err) {
if (err instanceof MfaRequiredError) {
// Read method preference from localStorage
const pref = localStorage.getItem('mfa_method_preference');
if (pref === 'webauthn') {
setMode('mfaWebauthn');
} else if (pref === 'totp') {
setMode('mfaVerify');
} else {
// No preference — default to method picker if available, else TOTP
setMode('mfaMethodPicker');
}
return;
}
// ... existing error handling
}
```
- [ ] **Step 5: Add the mfaMethodPicker render block**
In the render section, after the existing `mfaBackupCode` block, add:
```tsx
{mode === 'mfaMethodPicker' && (
<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**
```tsx
{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:
```tsx
<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:
```typescript
localStorage.setItem('mfa_method_preference', 'totp');
```
In `handleWebAuthnVerify`, before the redirect:
```typescript
localStorage.setItem('mfa_method_preference', 'webauthn');
```
- [ ] **Step 9: Auto-trigger passkey on entering mfaWebauthn mode**
Add a `useEffect` that triggers the passkey prompt automatically when the mode changes to `mfaWebauthn`:
```typescript
useEffect(() => {
if (mode === 'mfaWebauthn') {
handleWebAuthnVerify();
}
}, [mode]);
```
Note: Wrap `handleWebAuthnVerify` in `useCallback` or define it outside the effect scope to avoid lint warnings.
- [ ] **Step 10: Verify build**
Run: `cd ui/sign-in && npm run build`
Expected: No errors
- [ ] **Step 11: Commit**
```bash
git add ui/sign-in/src/SignInPage.tsx
git commit -m "feat: add WebAuthn and method picker modes to sign-in UI"
```
---
### Task 16: Post-Sign-In Passkey Nudge
**Files:**
- Modify: `ui/src/pages/tenant/SettingsPage.tsx` (or a shared layout component)
- [ ] **Step 1: Add nudge banner to SettingsPage**
At the top of the `SettingsPage` component render, add a nudge banner that shows when the user has no passkeys and the URL has `?passkey=nudge`:
```tsx
function PasskeyNudgeBanner() {
const { data: status } = useMfaStatus();
const [dismissed, setDismissed] = useState(false);
// Check if nudge was recently dismissed
const lastDismissed = localStorage.getItem('passkey_nudge_dismissed');
const recentlyDismissed = lastDismissed && (Date.now() - Number(lastDismissed)) < 30 * 24 * 60 * 60 * 1000;
if (dismissed || recentlyDismissed || !status || status.passkeyEnrolled) return null;
function handleDismiss() {
localStorage.setItem('passkey_nudge_dismissed', String(Date.now()));
setDismissed(true);
}
return (
<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**
```bash
git add ui/src/pages/tenant/SettingsPage.tsx
git commit -m "feat: add passkey enrollment nudge banner on settings page"
```
---
### Task 17: Onboarding Wizard Passkey Step
**Files:**
- Modify: `ui/src/pages/OnboardingPage.tsx`
- [ ] **Step 1: Add optional passkey step**
After the existing tenant creation success (when the form succeeds and before redirect), add a passkey offer state:
```tsx
const [showPasskeyOffer, setShowPasskeyOffer] = useState(false);
```
After the `POST /onboarding/tenant` succeeds (in the submit handler), instead of immediately redirecting, check if passkeys are enabled:
```tsx
// After tenant creation succeeds:
const config = await fetch('/platform/api/config').then(r => r.json());
if (config.vendorAuthPolicy?.passkeyEnabled) {
setShowPasskeyOffer(true);
} else {
// Existing redirect logic
await signIn();
navigate('/');
}
```
Add the passkey offer UI that shows when `showPasskeyOffer` is true:
```tsx
{showPasskeyOffer && (
<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.
```tsx
async function handleSkipPasskey() {
await signIn();
navigate('/');
}
```
- [ ] **Step 2: Commit**
```bash
git add ui/src/pages/OnboardingPage.tsx
git commit -m "feat: add passkey offer step to onboarding wizard"
```
---
### Task 18: Verify Full Build and Integration
**Files:** None (verification only)
- [ ] **Step 1: Build backend**
Run: `./mvnw compile -q`
Expected: BUILD SUCCESS
- [ ] **Step 2: Build frontend**
Run: `cd ui && npm run build && cd ../ui/sign-in && npm run build`
Expected: Both succeed with no errors
- [ ] **Step 3: Start the application locally**
Run: `docker compose up -d` (or local dev startup)
Verify:
- App starts without errors
- `GET /platform/api/config` returns `vendorAuthPolicy` field
- Vendor admin can access `/platform/vendor/auth-policy`
- Tenant settings page shows Passkey section and Auth Policy section
- [ ] **Step 4: Test vendor auth policy CRUD**
```
GET /platform/api/vendor/auth-policy → { mfaMode: "off", passkeyEnabled: false, passkeyMode: "optional" }
PUT /platform/api/vendor/auth-policy { mfaMode: "required" } → 200
GET /platform/api/vendor/auth-policy → { mfaMode: "required", ... }
```
- [ ] **Step 5: Test tenant auth settings**
```
GET /platform/api/tenant/auth-settings → { mfaMode: "off", passkeyEnabled: false, passkeyMode: "optional" }
PUT /platform/api/tenant/auth-settings { passkeyEnabled: true } → 200
GET /platform/api/tenant/{slug}/mfa-policy → includes passkeyEnabled: true
```
- [ ] **Step 6: Test passkey list endpoint**
```
GET /platform/api/tenant/mfa/webauthn → [] (empty list initially)
```
- [ ] **Step 7: Test sign-in MFA flow**
1. Enable MFA for tenant
2. Sign in → should show method picker (or TOTP depending on enrollment)
3. The "Use passkey" button should attempt WebAuthn authentication
- [ ] **Step 8: Final commit if any fixes needed**
```bash
git add -A
git commit -m "fix: integration fixes for passkey MFA feature"
```