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>
1954 lines
65 KiB
Markdown
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)} · 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"
|
|
```
|