From 4df6fc9e03ce977a5bb911317259bb380196bd79 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:23:41 +0200 Subject: [PATCH] fix: proxy passkey bind through Management API 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) --- .../saas/account/AccountController.java | 11 +++++++++ .../cameleer/saas/account/AccountService.java | 4 ++++ .../saas/identity/LogtoManagementClient.java | 12 ++++++++++ ui/src/api/logto-account-api.ts | 23 +++++++------------ 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java b/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java index 21718ec..6df0749 100644 --- a/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java +++ b/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java @@ -103,6 +103,17 @@ public class AccountController { 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 bindWebAuthn(@AuthenticationPrincipal Jwt jwt, + @RequestBody WebAuthnBindRequest request) { + accountService.bindWebAuthnPasskey(jwt.getSubject(), request.verificationRecordId(), request.identityVerificationId()); + return ResponseEntity.noContent().build(); + } + // --- MFA Preference --- @PostMapping("/mfa/method-preference") diff --git a/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java b/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java index 62483a2..22b2df7 100644 --- a/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java +++ b/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java @@ -150,6 +150,10 @@ public class AccountService { } } + public void bindWebAuthnPasskey(String userId, String verificationRecordId, String identityVerificationId) { + logtoClient.bindWebAuthnMfa(userId, verificationRecordId, identityVerificationId); + } + // --- Passkeys --- public List listPasskeys(String userId) { diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java index 9042a51..f79d128 100644 --- a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java +++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java @@ -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). */ public void enableAccountCenter() { if (!isAvailable()) return; diff --git a/ui/src/api/logto-account-api.ts b/ui/src/api/logto-account-api.ts index 51a7d11..6cd6298 100644 --- a/ui/src/api/logto-account-api.ts +++ b/ui/src/api/logto-account-api.ts @@ -1,8 +1,10 @@ import { startRegistration } from '@simplewebauthn/browser'; +import { api } from './client'; /** * Logto Account API client for WebAuthn passkey registration. * Calls Logto's Account API endpoints directly (same domain). + * The bind step goes through the SaaS backend (Management API). */ async function accountApi( @@ -72,19 +74,10 @@ export async function registerPasskey( const verifyData = await verifyRes.json(); const verifiedRecordId = verifyData.verificationRecordId; - // Step 5: Bind the passkey — requires logto-verification-id header for sensitive op - const bindRes = await accountApi( - 'POST', - '/my-account/mfa-verifications', - token, - { - 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})`); - } + // Step 5: Bind via SaaS backend (Management API) — Logto's /api/my-account/ + // rejects the opaque token, so we proxy through our backend. + await api.post('/account/mfa/webauthn/bind', { + verificationRecordId: verifiedRecordId, + identityVerificationId, + }); }