diff --git a/src/services/PasskeyAuthService.js b/src/services/PasskeyAuthService.js index 6329e072..f4db0a61 100644 --- a/src/services/PasskeyAuthService.js +++ b/src/services/PasskeyAuthService.js @@ -45,6 +45,57 @@ const base64URLToArrayBuffer = (base64url) => { return bytes.buffer; }; +/** + * Request and serialize a WebAuthn assertion from server-provided options. + * The caller remains responsible for submitting it to the endpoint that issued the challenge. + */ +export const requestPasskeyAssertion = async (publicKey) => { + if (!isPasskeySupported()) { + throw new Error('Passkeys are not supported on this device'); + } + + const serverRpId = publicKey.rpId; + const currentHostname = window.location.hostname; + const useServerRpId = serverRpId && ( + currentHostname === serverRpId || + currentHostname.endsWith('.' + serverRpId) + ); + const requestOptions = { + challenge: base64URLToArrayBuffer(publicKey.challenge), + timeout: publicKey.timeout || 60000, + userVerification: publicKey.userVerification || 'preferred', + rpId: useServerRpId ? serverRpId : currentHostname, + }; + + if (publicKey.allowCredentials && publicKey.allowCredentials.length > 0) { + requestOptions.allowCredentials = publicKey.allowCredentials.map(cred => ({ + id: base64URLToArrayBuffer(cred.id), + type: cred.type || 'public-key', + transports: cred.transports || ['internal', 'hybrid'], + })); + } + + const credential = await navigator.credentials.get({ publicKey: requestOptions }); + if (!credential) { + throw new Error('No credential returned from authenticator'); + } + + return { + id: credential.id, + rawId: arrayBufferToBase64URL(credential.rawId), + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {}, + response: { + clientDataJSON: arrayBufferToBase64URL(credential.response.clientDataJSON), + authenticatorData: arrayBufferToBase64URL(credential.response.authenticatorData), + signature: arrayBufferToBase64URL(credential.response.signature), + userHandle: credential.response.userHandle + ? arrayBufferToBase64URL(credential.response.userHandle) + : null, + }, + }; +}; + /** * Request authentication challenge from server * @param {number|null} customerNumber - Optional customer number for user login @@ -113,56 +164,7 @@ export const authenticateWithPasskey = async (userType = 'user', customerNumber const challengeToken = challengeResponse.challenge_token; const publicKey = challengeResponse.publicKey; - // Prepare credential request options from server response - // For rpId: only use server's rpId if current hostname ends with it (valid subdomain/domain match) - // Otherwise fallback to current hostname (e.g., localhost in development) - const serverRpId = publicKey.rpId; - const currentHostname = window.location.hostname; - const useServerRpId = serverRpId && ( - currentHostname === serverRpId || - currentHostname.endsWith('.' + serverRpId) - ); - - const publicKeyCredentialRequestOptions = { - challenge: base64URLToArrayBuffer(publicKey.challenge), - timeout: publicKey.timeout || 60000, - userVerification: publicKey.userVerification || 'preferred', - rpId: useServerRpId ? serverRpId : currentHostname, - }; - - // Add allowCredentials if provided by server - if (publicKey.allowCredentials && publicKey.allowCredentials.length > 0) { - publicKeyCredentialRequestOptions.allowCredentials = publicKey.allowCredentials.map(cred => ({ - id: base64URLToArrayBuffer(cred.id), - type: cred.type || 'public-key', - transports: cred.transports || ['internal', 'hybrid'], - })); - } - - // Request credential from authenticator - const credential = await navigator.credentials.get({ - publicKey: publicKeyCredentialRequestOptions, - }); - - if (!credential) { - throw new Error('No credential returned from authenticator'); - } - - // Prepare credential object for server verification (matching openapi spec) - const credentialForServer = { - id: credential.id, - rawId: arrayBufferToBase64URL(credential.rawId), - type: credential.type, - clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {}, - response: { - clientDataJSON: arrayBufferToBase64URL(credential.response.clientDataJSON), - authenticatorData: arrayBufferToBase64URL(credential.response.authenticatorData), - signature: arrayBufferToBase64URL(credential.response.signature), - userHandle: credential.response.userHandle - ? arrayBufferToBase64URL(credential.response.userHandle) - : null, - }, - }; + const credentialForServer = await requestPasskeyAssertion(publicKey); // Send credential to server for verification with challenge token return await verifyAuthentication(credentialForServer, challengeToken, recaptchaToken); diff --git a/src/services/accountDeletion.js b/src/services/accountDeletion.js index ea43c5aa..351c17ad 100644 --- a/src/services/accountDeletion.js +++ b/src/services/accountDeletion.js @@ -1,4 +1,5 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue"; +import { requestPasskeyAssertion } from "@/services/PasskeyAuthService.js"; export const ACCOUNT_DELETION_STATUSES = Object.freeze([ "available", @@ -6,6 +7,7 @@ export const ACCOUNT_DELETION_STATUSES = Object.freeze([ "processing", "completed", "failed", + "manual_review", ]); const DEFAULT_CONFIRMATION_PHRASE = "SLET MIN KONTO"; @@ -45,22 +47,44 @@ export const getAccountDeletionState = async () => { return normalizeAccountDeletionState(responseData(response)); }; +export const getAccountDeletionPasskeyAssertion = async () => { + const response = await SessionUser.request("/account/deletion/passkey/challenge", "POST", {}); + const challenge = responseData(response); + if (!challenge?.challenge_token || !challenge?.publicKey) { + throw new Error("Invalid account deletion passkey challenge"); + } + return { + passkeyChallengeToken: challenge.challenge_token, + passkeyCredential: await requestPasskeyAssertion(challenge.publicKey), + }; +}; + export const submitAccountDeletion = async ({ password, twoFactorCode, confirmation, acknowledgeLegalRetention, + passkeyChallengeToken, + passkeyCredential, }) => { const payload = { - password: String(password ?? ""), confirmation: String(confirmation ?? ""), acknowledge_legal_retention: acknowledgeLegalRetention === true, }; + const normalizedPassword = String(password ?? ""); + if (normalizedPassword) { + payload.password = normalizedPassword; + } + const normalizedTwoFactorCode = String(twoFactorCode ?? "").trim(); if (normalizedTwoFactorCode) { payload.two_factor_code = normalizedTwoFactorCode; } + if (passkeyChallengeToken && passkeyCredential) { + payload.passkey_challenge_token = String(passkeyChallengeToken); + payload.passkey_credential = passkeyCredential; + } const response = await SessionUser.request("/account/deletion", "POST", payload); return responseData(response); diff --git a/src/views/dashboards/userDashboard/profile/displays/AccountDeletion/AccountDeletionCard.vue b/src/views/dashboards/userDashboard/profile/displays/AccountDeletion/AccountDeletionCard.vue index 0cbddf9a..1113c860 100644 --- a/src/views/dashboards/userDashboard/profile/displays/AccountDeletion/AccountDeletionCard.vue +++ b/src/views/dashboards/userDashboard/profile/displays/AccountDeletion/AccountDeletionCard.vue @@ -6,6 +6,7 @@ import Swal from "sweetalert2"; import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { + getAccountDeletionPasskeyAssertion, getAccountDeletionState, submitAccountDeletion, } from "@/services/accountDeletion.js"; @@ -37,6 +38,7 @@ const statusLabel = computed(() => { case "completed": return t("user_dashboard.profile.deletion.status.completed"); case "failed": + case "manual_review": return t("user_dashboard.profile.deletion.status.failed"); default: return t("user_dashboard.profile.deletion.status.unavailable"); @@ -213,8 +215,22 @@ const requestDeletion = async () => { return; } - const password = await collectPassword(); - if (password === null) { + let reauthentication; + try { + if (deletionState.value?.password_required === false) { + reauthentication = await getAccountDeletionPasskeyAssertion(); + } else { + const password = await collectPassword(); + if (password === null) return; + reauthentication = { password }; + } + } catch (error) { + await Swal.fire({ + title: t("user_dashboard.profile.deletion.error_title"), + text: errorMessage(error), + icon: "error", + confirmButtonText: t("user_dashboard.profile.deletion.close"), + }); return; } const twoFactorCode = await collectTwoFactorCode(); @@ -232,7 +248,7 @@ const requestDeletion = async () => { isSubmitting.value = true; try { await submitAccountDeletion({ - password, + ...reauthentication, twoFactorCode, confirmation, acknowledgeLegalRetention: true, diff --git a/tests/unit/account-deletion.spec.js b/tests/unit/account-deletion.spec.js index ed6515ca..917852dc 100644 --- a/tests/unit/account-deletion.spec.js +++ b/tests/unit/account-deletion.spec.js @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ request: vi.fn(), + requestPasskeyAssertion: vi.fn(), forceClearSession: vi.fn(), login: vi.fn(), subuserLogin: vi.fn(), @@ -26,6 +27,10 @@ vi.mock("sweetalert2", () => ({ }, })); +vi.mock("@/services/PasskeyAuthService.js", () => ({ + requestPasskeyAssertion: mocks.requestPasskeyAssertion, +})); + vi.mock("@/components/session/token/SessionUser.vue", () => ({ SessionUser: { isSubuser: mocks.isSubuser, @@ -48,6 +53,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({ import AccountDeletionCard from "@/views/dashboards/userDashboard/profile/displays/AccountDeletion/AccountDeletionCard.vue"; import { getAccountDeletionState, + getAccountDeletionPasskeyAssertion, isAccountDeletionEligible, normalizeAccountDeletionState, submitAccountDeletion, @@ -80,6 +86,7 @@ const mountCard = () => describe("account deletion service", () => { beforeEach(() => { mocks.request.mockReset(); + mocks.requestPasskeyAssertion.mockReset(); }); it("normalizes untrusted state fields and defaults the confirmation phrase", () => { @@ -127,6 +134,21 @@ describe("account deletion service", () => { expect(normalizeAccountDeletionState({}).password_required).toBe(true); }); + it("binds passwordless deletion to a fresh deletion-specific passkey assertion", async () => { + const credential = { id: "credential-id", response: { signature: "signature" } }; + mocks.request.mockResolvedValueOnce({ + data: { data: { challenge_token: "deletion-token", publicKey: { challenge: "Y2hhbGxlbmdl" } } }, + }); + mocks.requestPasskeyAssertion.mockResolvedValueOnce(credential); + + await expect(getAccountDeletionPasskeyAssertion()).resolves.toEqual({ + passkeyChallengeToken: "deletion-token", + passkeyCredential: credential, + }); + expect(mocks.request).toHaveBeenCalledWith("/account/deletion/passkey/challenge", "POST", {}); + expect(mocks.requestPasskeyAssertion).toHaveBeenCalledWith({ challenge: "Y2hhbGxlbmdl" }); + }); + it("only exposes deletion to drivers and real customer accounts", () => { expect(isAccountDeletionEligible({ isSubuser: true, customerNumber: 0 })).toBe(true); expect(isAccountDeletionEligible({ isSubuser: false, customerNumber: 42 })).toBe(true); @@ -138,6 +160,7 @@ describe("account deletion service", () => { describe("AccountDeletionCard", () => { beforeEach(() => { mocks.request.mockReset(); + mocks.requestPasskeyAssertion.mockReset(); mocks.forceClearSession.mockReset(); mocks.login.mockReset(); mocks.subuserLogin.mockReset(); @@ -221,4 +244,33 @@ describe("AccountDeletionCard", () => { }) ); }); + + it("uses a fresh passkey assertion instead of an empty password for passwordless deletion", async () => { + const credential = { id: "credential-id", response: { signature: "signature" } }; + mocks.request + .mockResolvedValueOnce({ data: { data: availableState({ password_required: false }) } }) + .mockResolvedValueOnce({ + data: { data: { challenge_token: "deletion-token", publicKey: { challenge: "Y2hhbGxlbmdl" } } }, + }) + .mockResolvedValueOnce({ status: 202, data: { data: { status: "requested" } } }); + mocks.requestPasskeyAssertion.mockResolvedValueOnce(credential); + mocks.swalFire + .mockResolvedValueOnce({ isConfirmed: true }) + .mockResolvedValueOnce({ isConfirmed: true, value: "SLET MIN KONTO" }) + .mockResolvedValueOnce({ isConfirmed: true, value: 1 }) + .mockResolvedValueOnce({ isConfirmed: true }); + + const wrapper = mountCard(); + await flushPromises(); + await wrapper.get('[data-testid="account-deletion-start"]').trigger("click"); + await flushPromises(); + + expect(mocks.request).toHaveBeenLastCalledWith("/account/deletion", "POST", { + passkey_challenge_token: "deletion-token", + passkey_credential: credential, + confirmation: "SLET MIN KONTO", + acknowledge_legal_retention: true, + }); + expect(mocks.forceClearSession).toHaveBeenCalledOnce(); + }); });