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:
Jeppe B
2026-07-22 20:05:37 +02:00
committed by GitHub
parent 4c7d8c6f2e
commit f0e3c4812b
4 changed files with 148 additions and 54 deletions
+52 -50
View File
@@ -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);
+25 -1
View File
@@ -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);
@@ -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,
+52
View File
@@ -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();
});
});