Require fresh passkeys for passwordless account deletion (#215)
## Summary - Complete the frontend contract for hardened backend account deletion (#319). - For passwordless accounts, request a fresh deletion-specific WebAuthn challenge and submit its serialized assertion. - Reuse the existing passkey assertion serializer instead of duplicating WebAuthn conversion logic. - Accept the durable `manual_review` backend state while presenting the existing safe failure copy. ## Verification - Account deletion unit tests: 9/9. - Focused ESLint passed for all four changed files. - Node syntax checks and `git diff --check` passed. Backend rollout flags remain default-off; this UI is inert until #319 schema checks and explicit API enablement are completed.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+19
-3
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user