fix: proxy passkey bind through Management API
All checks were successful
CI / build (push) Successful in 2m17s
CI / docker (push) Successful in 1m29s

Logto's /api/my-account/ endpoints reject the opaque access token with
401 even though /api/verifications/ accepts it. The bind step now goes
through the SaaS backend which calls the Management API instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 18:23:41 +02:00
parent 2aa5100530
commit 4df6fc9e03
4 changed files with 35 additions and 15 deletions

View File

@@ -103,6 +103,17 @@ public class AccountController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
// --- WebAuthn bind (proxied via Management API since Account API /my-account/ returns 401) ---
record WebAuthnBindRequest(String verificationRecordId, String identityVerificationId) {}
@PostMapping("/mfa/webauthn/bind")
public ResponseEntity<Void> bindWebAuthn(@AuthenticationPrincipal Jwt jwt,
@RequestBody WebAuthnBindRequest request) {
accountService.bindWebAuthnPasskey(jwt.getSubject(), request.verificationRecordId(), request.identityVerificationId());
return ResponseEntity.noContent().build();
}
// --- MFA Preference --- // --- MFA Preference ---
@PostMapping("/mfa/method-preference") @PostMapping("/mfa/method-preference")

View File

@@ -150,6 +150,10 @@ public class AccountService {
} }
} }
public void bindWebAuthnPasskey(String userId, String verificationRecordId, String identityVerificationId) {
logtoClient.bindWebAuthnMfa(userId, verificationRecordId, identityVerificationId);
}
// --- Passkeys --- // --- Passkeys ---
public List<PasskeyCredential> listPasskeys(String userId) { public List<PasskeyCredential> listPasskeys(String userId) {

View File

@@ -653,6 +653,18 @@ public class LogtoManagementClient {
} }
} }
/** Bind a WebAuthn passkey to a user via the Management API. */
public void bindWebAuthnMfa(String userId, String verificationRecordId, String identityVerificationId) {
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("type", "WebAuthn", "verificationRecordId", verificationRecordId))
.retrieve()
.toBodilessEntity();
}
/** Enable the Account Center API for MFA management (WebAuthn passkey registration). */ /** Enable the Account Center API for MFA management (WebAuthn passkey registration). */
public void enableAccountCenter() { public void enableAccountCenter() {
if (!isAvailable()) return; if (!isAvailable()) return;

View File

@@ -1,8 +1,10 @@
import { startRegistration } from '@simplewebauthn/browser'; import { startRegistration } from '@simplewebauthn/browser';
import { api } from './client';
/** /**
* Logto Account API client for WebAuthn passkey registration. * Logto Account API client for WebAuthn passkey registration.
* Calls Logto's Account API endpoints directly (same domain). * Calls Logto's Account API endpoints directly (same domain).
* The bind step goes through the SaaS backend (Management API).
*/ */
async function accountApi( async function accountApi(
@@ -72,19 +74,10 @@ export async function registerPasskey(
const verifyData = await verifyRes.json(); const verifyData = await verifyRes.json();
const verifiedRecordId = verifyData.verificationRecordId; const verifiedRecordId = verifyData.verificationRecordId;
// Step 5: Bind the passkey — requires logto-verification-id header for sensitive op // Step 5: Bind via SaaS backend (Management API) — Logto's /api/my-account/
const bindRes = await accountApi( // rejects the opaque token, so we proxy through our backend.
'POST', await api.post('/account/mfa/webauthn/bind', {
'/my-account/mfa-verifications', verificationRecordId: verifiedRecordId,
token, identityVerificationId,
{ });
type: 'WebAuthn',
newIdentifierVerificationRecordId: verifiedRecordId,
},
{ 'logto-verification-id': identityVerificationId },
);
if (!bindRes.ok) {
const err = await bindRes.json().catch(() => ({}));
throw new Error(err.message || `Failed to bind passkey (${bindRes.status})`);
}
} }