docs: add passkey MFA design spec
Logto-native WebAuthn approach with independent vendor/tenant policy domains, three registration entry points, and smart MFA method defaults. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
381
docs/superpowers/specs/2026-04-27-passkey-mfa-design.md
Normal file
381
docs/superpowers/specs/2026-04-27-passkey-mfa-design.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Passkey MFA Design
|
||||
|
||||
**Date:** 2026-04-27
|
||||
**Status:** Draft
|
||||
**Goal:** Add passkeys (WebAuthn) as an MFA factor alongside existing TOTP, with a long-term path toward passwordless sign-in.
|
||||
|
||||
## Motivation
|
||||
|
||||
Passkeys provide a phishing-resistant, convenient alternative to TOTP codes. Short-term, they serve as an additional MFA option (fingerprint/Face ID instead of typing a 6-digit code). Long-term, they enable passwordless sign-in once adoption is sufficient.
|
||||
|
||||
## Approach
|
||||
|
||||
**Logto-native WebAuthn (Approach A).** All WebAuthn ceremony handling, credential storage, and factor management stays in Logto via the Experience API and Management API. The SaaS backend adds hierarchical policy enforcement and exposes Logto's credential data through its own API. No custom WebAuthn libraries, no credential mirroring — we work within what Logto provides.
|
||||
|
||||
## 1. Policy Model
|
||||
|
||||
### Two Independent Policy Domains
|
||||
|
||||
Vendor and tenant policies are **independent scopes**, not a hierarchy. No inheritance, no floor.
|
||||
|
||||
| Policy | Scope | Who it affects | Who sets it |
|
||||
|--------|-------|---------------|-------------|
|
||||
| **Vendor auth policy** | SaaS platform logins (`/platform/*`) | Tenant admins accessing the management plane | Platform admin (vendor) |
|
||||
| **Tenant auth policy** | Tenant server/dashboard logins | Org users accessing the tenant's cameleer-server | Tenant admin |
|
||||
|
||||
**Example scenario:** Vendor requires passkey for tenant admin platform logins. A tenant admin sets `mfa_mode: off` for their org. Result: that tenant admin must use a passkey to access the SaaS platform, but their end users sign into the cameleer dashboard with just username/password.
|
||||
|
||||
### Policy Settings
|
||||
|
||||
Each policy domain has three settings:
|
||||
|
||||
| Setting | Values | Default | Meaning |
|
||||
|---------|--------|---------|---------|
|
||||
| `mfa_mode` | `off`, `optional`, `required` | `off` | Whether MFA is required for sign-in |
|
||||
| `passkey_enabled` | `true`, `false` | `false` | Whether passkeys are available as a factor |
|
||||
| `passkey_mode` | `optional`, `preferred`, `required` | `optional` | Passkey enforcement level (only relevant when `passkey_enabled: true`) |
|
||||
|
||||
- `passkey_mode: optional` — User can choose passkey or TOTP
|
||||
- `passkey_mode: preferred` — Passkey prompted first, TOTP available as fallback
|
||||
- `passkey_mode: required` — Passkey is the only accepted MFA factor
|
||||
|
||||
### Storage
|
||||
|
||||
- **Vendor policy:** `vendor_auth_policy` single-row table with a management endpoint for runtime updates. Changes survive restarts without redeployment.
|
||||
- **Tenant policy:** Existing `settings` JSONB column on `tenants` table, extending the current `mfaRequired` key. New keys: `mfaMode`, `passkeyEnabled`, `passkeyMode`. The existing `mfaRequired: true` maps to `mfaMode: "required"` (backward-compatible).
|
||||
|
||||
### Enforcement
|
||||
|
||||
- **`MfaEnforcementFilter`** expands to cover two route groups:
|
||||
- `/api/vendor/**`, `/api/portal/**` — Checked against vendor auth policy
|
||||
- `/api/tenant/**` — Checked against tenant auth policy (from tenant `settings`)
|
||||
- Filter reads JWT claims `mfa_enrolled` and `passkey_enrolled` to determine user's factor status
|
||||
- Error codes returned:
|
||||
- `MFA_REQUIRED` — User must enroll in some MFA factor
|
||||
- `PASSKEY_REQUIRED` — Passkey specifically required by policy
|
||||
- Frontend uses these error codes to route users to the correct enrollment flow
|
||||
|
||||
## 2. Passkey Flows
|
||||
|
||||
### 2.1 Registration (Three Entry Points)
|
||||
|
||||
All three entry points use the same underlying WebAuthn registration ceremony via Logto Experience API.
|
||||
|
||||
**Settings page (deliberate enrollment):**
|
||||
1. User navigates to MFA settings in platform UI
|
||||
2. Clicks "Add passkey"
|
||||
3. Frontend calls SaaS backend `POST /api/tenant/mfa/webauthn/register/start`
|
||||
4. Backend calls Logto Management API to create a WebAuthn verification for the user, returns WebAuthn registration options (challenge, RP info, user info)
|
||||
5. Browser executes `navigator.credentials.create()` with the options via `@simplewebauthn/browser`
|
||||
6. Frontend sends the credential attestation to `POST /api/tenant/mfa/webauthn/register/complete`
|
||||
7. Backend forwards attestation to Logto Management API to complete registration, passkey appears in device list
|
||||
|
||||
**Post-sign-in nudge (organic adoption):**
|
||||
1. User signs in with password + TOTP (or password only if MFA optional)
|
||||
2. If passkey is enabled for the policy domain and user has no passkeys enrolled, show a dismissible banner: "Sign in faster with a passkey"
|
||||
3. User clicks "Set up" → same registration ceremony as settings page
|
||||
4. User clicks "Not now" → dismiss for 30 days (stored in `localStorage`)
|
||||
|
||||
**Onboarding wizard (new users):**
|
||||
1. New step after account creation, before tenant provisioning: "Secure your account with a passkey"
|
||||
2. Same registration ceremony
|
||||
3. "Skip" button available — passkey is not forced during onboarding regardless of policy (user hasn't joined an org yet, so no policy applies)
|
||||
|
||||
### 2.2 Authentication (Sign-In Flow)
|
||||
|
||||
1. User enters email + password → Logto validates credentials
|
||||
2. If MFA required (per effective policy), check enrolled factors:
|
||||
- **Passkey + TOTP enrolled:** Show last-used method by default. "Use [other method] instead" link below.
|
||||
- **Passkey only:** WebAuthn assertion prompt
|
||||
- **TOTP only:** Existing TOTP code input (unchanged)
|
||||
- **Neither enrolled, MFA required:** Redirect to enrollment flow
|
||||
3. Smart default: read `mfa_method_preference` from JWT custom data claim. Updated on each successful verification. Sign-in UI reads this to decide which prompt to show first.
|
||||
4. On successful factor verification → Logto completes session, issues token with `mfa_enrolled: true` and `passkey_enrolled: true`
|
||||
|
||||
### 2.3 WebAuthn Ceremony Details
|
||||
|
||||
**Registration via settings page (Management API — user is already signed in):**
|
||||
```
|
||||
1. Frontend → SaaS backend: POST /api/tenant/mfa/webauthn/register/start
|
||||
2. SaaS backend → Logto Management API: POST /api/users/{userId}/mfa-verifications
|
||||
(type: "WebAuthn") → returns registration options (challenge, RP, user info)
|
||||
3. SaaS backend → Frontend: registration options
|
||||
4. Browser: navigator.credentials.create(options) → attestation response
|
||||
5. Frontend → SaaS backend: POST /api/tenant/mfa/webauthn/register/complete
|
||||
6. SaaS backend → Logto Management API: complete verification with attestation
|
||||
```
|
||||
|
||||
**Registration during sign-in (Experience API — user is mid-authentication):**
|
||||
```
|
||||
1. POST /api/experience/verification/web-authn/registration → get creation options
|
||||
2. Browser: navigator.credentials.create(options)
|
||||
3. POST /api/experience/verification/web-authn/registration/verify → send attestation
|
||||
4. POST /api/experience/identification → identify user
|
||||
5. POST /api/experience/submit → complete
|
||||
```
|
||||
|
||||
**Assertion during sign-in (Experience API — MFA step after password):**
|
||||
```
|
||||
1. POST /api/experience/verification/web-authn/authentication → get request options
|
||||
2. Browser: navigator.credentials.get(options)
|
||||
3. POST /api/experience/verification/web-authn/authentication/verify → send assertion
|
||||
Returns: verificationId
|
||||
4. POST /api/experience/identification → identify user
|
||||
5. POST /api/experience/submit → complete, returns redirectTo
|
||||
```
|
||||
|
||||
### 2.4 Device Management (Settings UI)
|
||||
|
||||
Users can manage their registered passkeys from the MFA settings page:
|
||||
|
||||
- **List:** Show all registered WebAuthn credentials — name (user-settable), user-agent from registration, creation date
|
||||
- **Rename:** Update credential name via Logto Management API
|
||||
- **Delete:** Remove credential via Management API. If it's the last passkey and passkey is required, block deletion.
|
||||
|
||||
**Logto metadata available per credential:**
|
||||
- `id` — credential identifier
|
||||
- `type` — `"WebAuthn"`
|
||||
- `name` — user-settable display name (nullable)
|
||||
- `agent` — user-agent string captured at registration time
|
||||
- `createdAt` — timestamp
|
||||
|
||||
**Not available from Logto:** `lastUsedAt` — Logto does not track last-used date. The UI will not show this field.
|
||||
|
||||
## 3. Backend Changes
|
||||
|
||||
### 3.1 Database Migration
|
||||
|
||||
**New table: `vendor_auth_policy`** (single-row config)
|
||||
|
||||
| Column | Type | Default | Purpose |
|
||||
|--------|------|---------|---------|
|
||||
| `id` | INTEGER | 1 | Single-row constraint |
|
||||
| `mfa_mode` | VARCHAR(10) | `'off'` | `off`, `optional`, `required` |
|
||||
| `passkey_enabled` | BOOLEAN | `false` | Whether passkeys available |
|
||||
| `passkey_mode` | VARCHAR(10) | `'optional'` | `optional`, `preferred`, `required` |
|
||||
| `updated_at` | TIMESTAMP | now() | Last update |
|
||||
|
||||
**Tenant settings JSONB extension:** Add new keys to the whitelist in `TenantPortalService.updateTenantSettings()`:
|
||||
|
||||
| Key | Type | Default | Purpose |
|
||||
|-----|------|---------|---------|
|
||||
| `mfaMode` | string | `"off"` | Replaces/supersedes `mfaRequired` |
|
||||
| `passkeyEnabled` | boolean | `false` | Whether passkeys available for tenant users |
|
||||
| `passkeyMode` | string | `"optional"` | Passkey enforcement level |
|
||||
|
||||
**Backward compatibility:** `mfaRequired: true` treated as `mfaMode: "required"`. The filter checks both: if `mfaMode` is present, use it; otherwise fall back to `mfaRequired`.
|
||||
|
||||
### 3.2 MfaEnforcementFilter Changes
|
||||
|
||||
Current behavior: only intercepts `/api/tenant/**`, checks `mfa_enrolled` claim against tenant `settings.mfaRequired`.
|
||||
|
||||
New behavior:
|
||||
- **Route matching:** Intercept `/api/vendor/**` and `/api/portal/**` in addition to `/api/tenant/**`
|
||||
- **Policy lookup:**
|
||||
- Vendor/portal routes → read `vendor_auth_policy` table
|
||||
- Tenant routes → read tenant `settings` (existing behavior, extended)
|
||||
- **Claim checks:**
|
||||
- `mfa_mode: required` → require `mfa_enrolled == true`
|
||||
- `passkey_mode: required` → require `passkey_enrolled == true`
|
||||
- `passkey_mode: preferred` → same as optional for enforcement (preference handled in sign-in UI)
|
||||
- **Error codes:**
|
||||
- `APP_MFA_REQUIRED` (existing) — MFA enrollment needed
|
||||
- `APP_PASSKEY_REQUIRED` (new) — Passkey specifically required
|
||||
- **Exempt routes:** Add `/api/vendor/auth-policy` and `/api/portal/auth-settings` to exempt list so admins can read/update policy without triggering enforcement
|
||||
|
||||
### 3.3 LogtoManagementClient Additions
|
||||
|
||||
New methods for WebAuthn credential management:
|
||||
|
||||
| Method | Logto Endpoint | Purpose |
|
||||
|--------|---------------|---------|
|
||||
| `listWebAuthnCredentials(userId)` | `GET /api/users/{userId}/mfa-verifications` | List credentials, filter by `type: "WebAuthn"` |
|
||||
| `deleteWebAuthnCredential(userId, verificationId)` | `DELETE /api/users/{userId}/mfa-verifications/{verificationId}` | Remove a passkey |
|
||||
| `renameWebAuthnCredential(userId, verificationId, name)` | `PATCH /api/users/{userId}/mfa-verifications/{verificationId}` | Update display name |
|
||||
| `updateUserCustomData(userId, data)` | `PATCH /api/users/{userId}/custom-data` | Set `mfa_method_preference` |
|
||||
|
||||
### 3.4 Custom JWT Script Update
|
||||
|
||||
Extend the existing `getCustomJwtClaims` function in `docker/logto-bootstrap.sh`:
|
||||
|
||||
```javascript
|
||||
const factors = context.user?.mfaVerificationFactors ?? [];
|
||||
return {
|
||||
roles: /* ... existing role mapping ... */,
|
||||
mfa_enrolled: factors.includes('Totp') || factors.includes('WebAuthn'),
|
||||
passkey_enrolled: factors.includes('WebAuthn'),
|
||||
mfa_method_preference: context.user?.customData?.mfa_method_preference ?? null,
|
||||
};
|
||||
```
|
||||
|
||||
**Changes from current script:**
|
||||
- `mfa_enrolled` now returns `true` for either TOTP or WebAuthn (was TOTP-only)
|
||||
- New `passkey_enrolled` boolean claim
|
||||
- New `mfa_method_preference` claim from user custom data
|
||||
|
||||
### 3.5 API Endpoints
|
||||
|
||||
**Vendor auth policy (platform:admin only):**
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/vendor/auth-policy` | Read current vendor auth policy |
|
||||
| `PUT` | `/api/vendor/auth-policy` | Update vendor auth policy |
|
||||
|
||||
**Tenant auth settings (tenant:manage scope):**
|
||||
|
||||
Extend existing `TenantPortalController`:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/tenant/auth-settings` | Read tenant auth policy |
|
||||
| `PUT` | `/api/tenant/auth-settings` | Update tenant auth policy |
|
||||
|
||||
**Passkey management (authenticated users):**
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/tenant/mfa/webauthn` | List user's passkey credentials |
|
||||
| `POST` | `/api/tenant/mfa/webauthn/register/start` | Initiate WebAuthn registration |
|
||||
| `POST` | `/api/tenant/mfa/webauthn/register/complete` | Complete WebAuthn registration |
|
||||
| `PATCH` | `/api/tenant/mfa/webauthn/{id}/name` | Rename a passkey |
|
||||
| `DELETE` | `/api/tenant/mfa/webauthn/{id}` | Delete a passkey |
|
||||
|
||||
**Public config extension:**
|
||||
|
||||
`GET /api/config` response adds:
|
||||
```json
|
||||
{
|
||||
"vendorAuthPolicy": { "mfaMode": "...", "passkeyEnabled": true, "passkeyMode": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
`GET /{slug}/mfa-policy` response extends to:
|
||||
```json
|
||||
{
|
||||
"mfaMode": "required",
|
||||
"passkeyEnabled": true,
|
||||
"passkeyMode": "preferred"
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Sign-In UI Changes
|
||||
|
||||
### 4.1 New Modes
|
||||
|
||||
Add to the `Mode` type in `SignInPage.tsx`:
|
||||
|
||||
| Mode | When shown | UI |
|
||||
|------|------------|-----|
|
||||
| `mfaWebauthn` | Passkey verification during sign-in | "Verifying your identity..." with browser passkey prompt |
|
||||
| `mfaMethodPicker` | User has both TOTP and passkey, choosing which to use | Two buttons: "Use passkey" / "Use authenticator code" |
|
||||
|
||||
### 4.2 Experience API Client Additions
|
||||
|
||||
New functions in `experience-api.ts`:
|
||||
|
||||
```typescript
|
||||
// Initiate WebAuthn authentication, returns challenge options
|
||||
async function startWebAuthnAuth(): Promise<WebAuthnOptions>
|
||||
|
||||
// Verify WebAuthn assertion response, returns verificationId
|
||||
async function verifyWebAuthnAuth(credential: PublicKeyCredential): Promise<string>
|
||||
|
||||
// Initiate WebAuthn registration, returns creation options
|
||||
async function startWebAuthnRegistration(): Promise<WebAuthnCreationOptions>
|
||||
|
||||
// Verify WebAuthn registration attestation
|
||||
async function verifyWebAuthnRegistration(credential: PublicKeyCredential): Promise<string>
|
||||
```
|
||||
|
||||
### 4.3 Sign-In Flow Changes
|
||||
|
||||
After password verification, when Logto returns an MFA challenge:
|
||||
|
||||
1. Read effective auth policy from `/api/config` or `/{slug}/mfa-policy`
|
||||
2. Read `mfa_method_preference` from the MFA challenge context (or default to passkey if `passkey_mode: preferred`)
|
||||
3. Route to:
|
||||
- `mfaWebauthn` mode if preference is passkey
|
||||
- `mfaVerify` mode (existing) if preference is TOTP
|
||||
- `mfaMethodPicker` if no preference set and both are enrolled
|
||||
4. Each mode shows a link to switch to the other method
|
||||
|
||||
### 4.4 Post-Sign-In Nudge
|
||||
|
||||
After successful sign-in, if:
|
||||
- Passkey is enabled in the effective policy
|
||||
- User has no passkeys enrolled
|
||||
- Nudge hasn't been dismissed in the last 30 days
|
||||
|
||||
Show a banner at the top of the platform UI (not in the sign-in UI — this happens after redirect back to the SPA).
|
||||
|
||||
### 4.5 WebAuthn Browser API
|
||||
|
||||
Use `@simplewebauthn/browser` for the client-side WebAuthn ceremony:
|
||||
- `startRegistration(options)` — wraps `navigator.credentials.create()`
|
||||
- `startAuthentication(options)` — wraps `navigator.credentials.get()`
|
||||
|
||||
This handles Base64URL encoding, browser compatibility, and platform authenticator detection.
|
||||
|
||||
## 5. Platform UI Changes
|
||||
|
||||
### 5.1 MFA Settings Page Extension
|
||||
|
||||
Add a "Passkeys" section below the existing TOTP section in `SettingsPage.tsx`:
|
||||
|
||||
**When no passkeys enrolled:**
|
||||
- "Add a passkey" button
|
||||
- Brief explanation: "Use your fingerprint, face, or security key to sign in"
|
||||
|
||||
**When passkeys exist:**
|
||||
- Table/list of registered passkeys showing:
|
||||
- Name (editable inline or via edit button)
|
||||
- Device info (parsed from user-agent string — "Chrome on Windows", "Safari on iPhone", etc.)
|
||||
- Created date
|
||||
- Delete button (with confirmation dialog)
|
||||
- "Add another passkey" button
|
||||
|
||||
### 5.2 Auth Policy Management
|
||||
|
||||
**Vendor settings (new page or section):**
|
||||
- Under vendor admin area, "Authentication Policy" section
|
||||
- Three controls matching the policy settings (mfa_mode dropdown, passkey_enabled toggle, passkey_mode dropdown)
|
||||
- Passkey_mode control disabled when passkey_enabled is false
|
||||
|
||||
**Tenant settings (extend existing):**
|
||||
- Current MFA toggle (`mfaRequired`) replaced with richer controls
|
||||
- Same three controls as vendor, scoped to tenant users
|
||||
- Backward-compatible: existing `mfaRequired: true` shown as `mfaMode: required`
|
||||
|
||||
### 5.3 Onboarding Wizard
|
||||
|
||||
Add optional passkey registration step to `OnboardingPage.tsx`:
|
||||
|
||||
- After org creation, before redirect
|
||||
- "Secure your account with a passkey" with setup and skip buttons
|
||||
- Only shown if passkeys are enabled in vendor policy (read from `/api/config`)
|
||||
|
||||
## 6. Passwordless Future Path
|
||||
|
||||
This design enables passwordless sign-in without additional architecture changes:
|
||||
|
||||
1. **`passkey_mode: required`** already means passkey is the only accepted factor
|
||||
2. When Logto supports passkey-as-primary-factor (passwordless sign-in), the sign-in UI adds a "Sign in with passkey" button on the initial screen (before email/password)
|
||||
3. The policy model already supports this: a future `auth_mode: passwordless` setting could skip the password step entirely
|
||||
4. No backend changes needed — the JWT claims and enforcement filter already handle passkey-only scenarios
|
||||
|
||||
This is not part of the current implementation scope but validates that the design doesn't paint us into a corner.
|
||||
|
||||
## 7. Out of Scope
|
||||
|
||||
- Passwordless sign-in (passkey as primary factor, no password) — future work
|
||||
- Conditional UI / autofill-assisted passkey discovery — depends on Logto Experience API support
|
||||
- Cross-device passkey sync management — handled by OS/browser, not our concern
|
||||
- FIDO2 attestation policy (which authenticators to trust) — Logto defaults are sufficient
|
||||
- Rate limiting on WebAuthn ceremonies — handled by Logto
|
||||
|
||||
## 8. Dependencies
|
||||
|
||||
- Logto v1.11.0+ (WebAuthn MFA support) — current `ghcr.io/logto-io/logto:latest` satisfies this
|
||||
- `@simplewebauthn/browser` npm package for sign-in UI and platform UI
|
||||
- No Java WebAuthn libraries needed (all ceremonies handled by Logto)
|
||||
Reference in New Issue
Block a user