feat(core): Multi-credential UX + last-used tracking + method-aware reverify

- Track lastUsedAt per webauthn credential (new column + migration)
  and surface it as "Last used X ago" in the credentials list.
- Personal settings restructured to one card per method with a nested
  credentials list, device-aware label pre-fill on the setup wizard,
  and a dashed "Add another …" button.
- Login MFA prompt picks the method the user signed in with last on
  this device (localStorage) and shows a switcher link when both
  TOTP and a webauthn credential are registered.
- Credential removal re-verifies with the same kind of authenticator
  being removed (passkey → passkey, security key → security key) via
  a narrowed allowCredentials, falling back to the TOTP / recovery
  code prompt when the device is unavailable.
- Refuse to delete the last MFA credential while 2FA is enforced.
This commit is contained in:
Ricardo Espinoza 2026-05-13 12:43:06 -04:00
parent 00bce46e5f
commit 0cc659eb99
No known key found for this signature in database
17 changed files with 609 additions and 213 deletions

View File

@ -7,4 +7,5 @@ export interface WebAuthnCredentialResponse {
transports: string[] | null;
aaguid: string | null;
createdAt: string;
lastUsedAt: string | null;
}

View File

@ -39,4 +39,7 @@ export class WebauthnCredential extends WithTimestampsAndStringId {
@Column({ type: String, length: 255 })
label: string;
@Column({ type: 'datetime', nullable: true })
lastUsedAt: Date | null;
}

View File

@ -0,0 +1,11 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddLastUsedAtToWebauthnCredential1783300000000 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('webauthn_credential', [column('lastUsedAt').timestamp()]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('webauthn_credential', ['lastUsedAt']);
}
}

View File

@ -175,6 +175,7 @@ import { AddEvaluationConfigColumnsToTestRun1778100002000 } from '../common/1778
import { CreateAgentTables1783000000000 } from '../common/1783000000000-CreateAgentTables';
import { CreateAgentExecutionTables1783000000001 } from '../common/1783000000001-CreateAgentExecutionTables';
import { AddTokensValidAfterToUser1783200000000 } from '../common/1783200000000-AddTokensValidAfterToUser';
import { AddLastUsedAtToWebauthnCredential1783300000000 } from '../common/1783300000000-AddLastUsedAtToWebauthnCredential';
import type { Migration } from '../migration-types';
export const postgresMigrations: Migration[] = [
@ -355,4 +356,5 @@ export const postgresMigrations: Migration[] = [
CreateAgentTables1783000000000,
CreateAgentExecutionTables1783000000001,
AddTokensValidAfterToUser1783200000000,
AddLastUsedAtToWebauthnCredential1783300000000,
];

View File

@ -168,6 +168,7 @@ import { AddEvaluationConfigColumnsToTestRun1778100002000 } from '../common/1778
import { CreateAgentTables1783000000000 } from '../common/1783000000000-CreateAgentTables';
import { CreateAgentExecutionTables1783000000001 } from '../common/1783000000001-CreateAgentExecutionTables';
import { AddTokensValidAfterToUser1783200000000 } from '../common/1783200000000-AddTokensValidAfterToUser';
import { AddLastUsedAtToWebauthnCredential1783300000000 } from '../common/1783300000000-AddLastUsedAtToWebauthnCredential';
import type { Migration } from '../migration-types';
const sqliteMigrations: Migration[] = [
@ -341,6 +342,7 @@ const sqliteMigrations: Migration[] = [
CreateAgentTables1783000000000,
CreateAgentExecutionTables1783000000001,
AddTokensValidAfterToUser1783200000000,
AddLastUsedAtToWebauthnCredential1783300000000,
];
export { sqliteMigrations };

View File

@ -3,7 +3,6 @@ import { InstanceSettingsLoaderConfig } from '@n8n/config';
import { AuthenticatedRequest, UserRepository } from '@n8n/db';
import {
createUserKeyedRateLimiter,
Delete,
Get,
GlobalScope,
Patch,
@ -317,8 +316,10 @@ export class MFAController {
@Post('/webauthn/authentication-options', {
skipAuth: true,
})
async getWebAuthnAuthenticationOptions(req: AuthenticatedRequest<{}, {}, { email: string }>) {
const { email } = req.body;
async getWebAuthnAuthenticationOptions(
req: AuthenticatedRequest<{}, {}, { email: string; kind?: 'passkey' | 'security_key' }>,
) {
const { email, kind } = req.body;
if (!email) {
throw new BadRequestError('Email is required');
}
@ -329,7 +330,8 @@ export class MFAController {
throw new BadRequestError('MFA Error', 998);
}
return await this.mfaService.webauthn.generateAuthenticationOptions(user.id);
const narrowedKind = kind === 'passkey' || kind === 'security_key' ? kind : undefined;
return await this.mfaService.webauthn.generateAuthenticationOptions(user.id, narrowedKind);
}
@Get('/webauthn/credentials')
@ -344,25 +346,73 @@ export class MFAController {
transports: c.transports,
aaguid: c.aaguid ?? null,
createdAt: c.createdAt.toISOString(),
lastUsedAt: c.lastUsedAt ? c.lastUsedAt.toISOString() : null,
}));
}
@Delete('/webauthn/credentials/:id')
async deleteWebAuthnCredential(req: AuthenticatedRequest<{ id: string }>, res: Response) {
// Use POST (not DELETE) so the proof body reliably reaches the server —
// our rest-api-client routes DELETE data through query params, which
// can't carry a structured webauthn assertion.
@Post('/webauthn/credentials/:id/remove')
async deleteWebAuthnCredential(
req: AuthenticatedRequest<
{ id: string },
{},
{ mfaCode?: string; mfaRecoveryCode?: string; webauthnResponse?: unknown }
>,
res: Response,
) {
const { id } = req.params;
const { mfaCode, mfaRecoveryCode, webauthnResponse } = req.body;
const userId = req.user.id;
// Removing a credential is security-sensitive — re-verify the user's
// active 2FA method (TOTP code, recovery code, or a fresh webauthn
// assertion) before we touch the credential row.
const provided = [mfaCode, mfaRecoveryCode, webauthnResponse].filter(
(v) => v !== undefined && v !== null && v !== '',
);
if (provided.length !== 1) {
throw new BadRequestError(
'Exactly one of mfaCode, mfaRecoveryCode, or webauthnResponse is required to remove this credential',
);
}
let proofValid: boolean;
if (webauthnResponse !== undefined && webauthnResponse !== null) {
proofValid = await this.mfaService.validateWebAuthn(userId, webauthnResponse);
} else {
proofValid = await this.mfaService.validateMfa(userId, mfaCode, mfaRecoveryCode);
}
if (!proofValid) {
throw new BadRequestError('Invalid two-factor proof');
}
const [credentialsBefore, isMFAEnforced, { decryptedSecret }] = await Promise.all([
this.mfaService.webauthn.getUserCredentials(userId),
this.mfaService.isMFAEnforced(),
this.mfaService.getSecretAndRecoveryCodes(userId),
]);
const remainingWebauthn = credentialsBefore.filter((c) => c.id !== id).length;
const hasTotpSecret = !!decryptedSecret;
// Refuse if removing this credential would leave the user with no
// authentication factor on an instance that enforces MFA.
if (isMFAEnforced && remainingWebauthn === 0 && !hasTotpSecret) {
throw new BadRequestError(
'You can’t remove your last MFA credential while two-factor authentication is required on this instance',
);
}
const deleted = await this.mfaService.webauthn.deleteCredential(id, userId);
if (!deleted) {
throw new BadRequestError('Credential not found');
}
// Check if user has any remaining MFA methods
const hasWebauthnCredentials = await this.mfaService.webauthn.hasCredentials(userId);
const { decryptedSecret } = await this.mfaService.getSecretAndRecoveryCodes(userId);
const hasTotpSecret = !!decryptedSecret;
if (!hasWebauthnCredentials && !hasTotpSecret) {
// If this was the last MFA factor, mirror disable-MFA semantics so the
// user's session reflects the new state. The pre-delete read above
// already told us what's left — no need to re-query.
if (remainingWebauthn === 0 && !hasTotpSecret) {
await this.userRepository.update(userId, {
mfaEnabled: false,
mfaSecret: null,

View File

@ -158,13 +158,24 @@ export class WebAuthnService {
);
}
async generateAuthenticationOptions(userId: string) {
async generateAuthenticationOptions(userId: string, kind?: 'passkey' | 'security_key') {
const { generateAuthenticationOptions } = await import('@simplewebauthn/server');
const credentials = await this.webauthnCredentialRepository.find({
const allCredentials = await this.webauthnCredentialRepository.find({
where: { userId },
});
// When the caller asks for a specific kind (e.g. re-verify with a passkey
// when removing one) we narrow `allowCredentials` so the browser/OS can
// only satisfy the ceremony with a matching credential.
const filterByKind = (c: (typeof allCredentials)[number]) => {
if (!kind) return true;
const isPlatform =
(c.transports ?? []).includes('internal') || c.deviceType === 'multiDevice';
return kind === 'passkey' ? isPlatform : !isPlatform;
};
const credentials = allCredentials.filter(filterByKind);
const options = await generateAuthenticationOptions({
rpID: this.getRpId(),
allowCredentials: credentials.map((cred) => ({
@ -231,6 +242,7 @@ export class WebAuthnService {
if (verification.verified && verification.authenticationInfo) {
await this.webauthnCredentialRepository.update(credential.id, {
counter: verification.authenticationInfo.newCounter,
lastUsedAt: new Date(),
});
}
@ -307,6 +319,7 @@ export class WebAuthnService {
await this.webauthnCredentialRepository.update(credential.id, {
counter: verification.authenticationInfo.newCounter,
lastUsedAt: new Date(),
});
await this.cacheService.delete(cacheKey);

View File

@ -4867,7 +4867,14 @@
"settings.personal.method.button.remove": "Remove",
"settings.personal.method.button.disable": "Disable",
"settings.personal.method.button.addAnother": "Add another",
"settings.personal.method.button.addAnother.passkey": "Add another passkey",
"settings.personal.method.button.addAnother.security_key": "Add another security key",
"settings.personal.method.badge.registeredOne": "1 registered",
"settings.personal.method.badge.registeredMany": "{count} registered",
"settings.personal.method.detail.setUpOn": "Set up {date}",
"settings.personal.method.detail.addedOn": "Added {date}",
"settings.personal.method.detail.lastUsed": "Last used {when}",
"settings.personal.method.detail.neverUsed": "Never used",
"settings.personal.twoFactor.section.title": "Two-factor authentication (2FA)",
"settings.personal.twoFactor.description": "Add a second step after your password. You can enable multiple methods at the same time.",
"settings.personal.twoFactor.disabled.description": "Two-factor authentication is currently disabled.",
@ -4928,6 +4935,9 @@
"mfa.login.security_key.waiting.title": "Touch your security key",
"mfa.login.security_key.waiting.description": "The key should be blinking. Touch the button on it now to verify.",
"mfa.login.cancel": "Cancel",
"mfa.login.switcher.useTotp": "Use authenticator app instead",
"mfa.login.switcher.useSecurityKey": "Use security key instead",
"mfa.login.switcher.usePasskey": "Use passkey instead",
"auth.changePassword.passkey.title": "Verify with your passkey",
"auth.changePassword.passkey.description": "Confirm with Touch ID, Face ID, or your device PIN to set a new password.",
"auth.changePassword.passkey.button": "Verify with passkey",

View File

@ -30,9 +30,14 @@ export async function verifyRegistration(
return await makeRestApiRequest(context, 'POST', '/mfa/webauthn/registration-verify', data);
}
export async function getAuthenticationOptions(context: IRestApiContext, email: string) {
export async function getAuthenticationOptions(
context: IRestApiContext,
email: string,
kind?: 'passkey' | 'security_key',
) {
return await makeRestApiRequest(context, 'POST', '/mfa/webauthn/authentication-options', {
email,
...(kind ? { kind } : {}),
});
}
@ -42,8 +47,18 @@ export async function getCredentials(
return await makeRestApiRequest(context, 'GET', '/mfa/webauthn/credentials');
}
export async function deleteCredential(context: IRestApiContext, id: string): Promise<void> {
return await makeRestApiRequest(context, 'DELETE', `/mfa/webauthn/credentials/${id}`);
export type DeleteCredentialProof = {
mfaCode?: string;
mfaRecoveryCode?: string;
webauthnResponse?: unknown;
};
export async function deleteCredential(
context: IRestApiContext,
id: string,
proof: DeleteCredentialProof,
): Promise<void> {
return await makeRestApiRequest(context, 'POST', `/mfa/webauthn/credentials/${id}/remove`, proof);
}
export async function updateCredential(

View File

@ -0,0 +1,6 @@
/**
* Per-device localStorage key remembering which 2FA method the user used last
* to sign in. Read on the MFA prompt to pick a sensible default and written
* after a successful sign-in. Stored values are `MfaMethod` strings.
*/
export const LAST_2FA_METHOD_KEY = 'n8n.last2faMethod';

View File

@ -18,6 +18,7 @@ import { useUIStore } from '@/app/stores/ui.store';
import { useToast } from '@/app/composables/useToast';
import { useUsersStore } from '@/features/settings/users/users.store';
import { twoFactorWizardBus, type TwoFactorMethod } from '../auth.eventBus';
import { suggestCredentialLabel } from '../utils/suggest-label';
import MfaWizardSteps from './MfaWizardSteps.vue';
const props = defineProps<{
@ -36,7 +37,7 @@ const attachment = computed<'platform' | 'cross-platform'>(() =>
method.value === 'passkey' ? 'platform' : 'cross-platform',
);
const label = ref('');
const label = ref(suggestCredentialLabel(navigator.userAgent, props.data?.method ?? 'passkey'));
const submitting = ref(false);
const recoveryCodes = ref<string[]>([]);
const showRecoveryCodes = computed(() => recoveryCodes.value.length > 0);

View File

@ -14,53 +14,52 @@ export type MfaProof =
* webauthn assertion ceremony. The caller forwards the returned proof to whichever
* endpoint requires re-verification (e.g. disable MFA, change password, change email).
*
* Pass `preferKind: 'passkey' | 'security_key'` to force a webauthn ceremony with
* a credential of that type (used when removing a credential — the user is asked
* to re-verify with the same kind of authenticator they're removing). When the
* user doesn't have any credentials of that kind, this falls back to the
* code/recovery-code prompt.
*
* Resolves with `null` if the user cancels.
*/
export function useMfaReverify() {
const usersStore = useUsersStore();
const uiStore = useUIStore();
async function reverify(): Promise<MfaProof | null> {
const promptCode = (): Promise<MfaProof | null> =>
new Promise<MfaProof | null>((resolve) => {
const handler = (payload: { mfaCode?: string; mfaRecoveryCode?: string } | undefined) => {
promptMfaCodeBus.off('closed', handler);
if (!payload) return resolve(null);
if (payload.mfaCode) return resolve({ mfaCode: payload.mfaCode });
if (payload.mfaRecoveryCode) return resolve({ mfaRecoveryCode: payload.mfaRecoveryCode });
resolve(null);
};
promptMfaCodeBus.on('closed', handler);
uiStore.openModal(PROMPT_MFA_CODE_MODAL_KEY);
});
async function reverify(preferKind?: 'passkey' | 'security_key'): Promise<MfaProof | null> {
const user = usersStore.currentUser;
if (!user || !user.mfaEnabled) return null;
const methods = user.availableMfaMethods ?? [];
const method = methods[0] ?? 'totp';
const chosen: 'totp' | 'passkey' | 'security_key' =
preferKind && methods.includes(preferKind) ? preferKind : (methods[0] ?? 'totp');
if (method === 'totp') {
return await new Promise<MfaProof | null>((resolve) => {
const handler = (payload: { mfaCode?: string; mfaRecoveryCode?: string } | undefined) => {
promptMfaCodeBus.off('closed', handler);
if (!payload) return resolve(null);
if (payload.mfaCode) return resolve({ mfaCode: payload.mfaCode });
if (payload.mfaRecoveryCode) return resolve({ mfaRecoveryCode: payload.mfaRecoveryCode });
resolve(null);
};
promptMfaCodeBus.on('closed', handler);
uiStore.openModal(PROMPT_MFA_CODE_MODAL_KEY);
});
if (chosen === 'totp') {
return await promptCode();
}
// webauthn — passkey or security_key
if (!user.email) return null;
try {
const webauthnResponse = await usersStore.verifyWebAuthnAuthentication(user.email);
const webauthnResponse = await usersStore.verifyWebAuthnAuthentication(user.email, chosen);
return { webauthnResponse };
} catch {
// Authenticator unavailable (lost device, cancelled ceremony) —
// fall back to the code/recovery-code prompt so users can still
// manage their account with a recovery code.
return await new Promise<MfaProof | null>((resolve) => {
const handler = (payload: { mfaCode?: string; mfaRecoveryCode?: string } | undefined) => {
promptMfaCodeBus.off('closed', handler);
if (!payload) return resolve(null);
if (payload.mfaCode) return resolve({ mfaCode: payload.mfaCode });
if (payload.mfaRecoveryCode) return resolve({ mfaRecoveryCode: payload.mfaRecoveryCode });
resolve(null);
};
promptMfaCodeBus.on('closed', handler);
uiStore.openModal(PROMPT_MFA_CODE_MODAL_KEY);
});
// Authenticator unavailable (lost device, cancelled ceremony) — fall
// back to the code/recovery-code prompt so users can still manage
// their account with a recovery code.
return await promptCode();
}
}

View File

@ -0,0 +1,34 @@
import Bowser from 'bowser';
/**
* Suggest a sensible default label for a freshly-registered passkey based on
* the current device. Returns `''` for security keys (the user is more likely
* to want a specific, inventory-style name like "YubiKey 5C (primary)") and
* for unknown platforms. The result is purely a UX hint — users can edit it
* freely before registration.
*/
export function suggestCredentialLabel(
userAgent: string,
method: 'passkey' | 'security_key',
): string {
if (method !== 'passkey') return '';
const { os } = Bowser.parse(userAgent);
switch (os.name) {
case 'iOS':
return 'iPhone';
case 'iPadOS':
return 'iPad';
case 'macOS':
return 'Mac';
case 'Android':
return 'Android device';
case 'Windows':
return 'Windows PC';
case 'Linux':
case 'Chrome OS':
return `${os.name} PC`;
default:
return '';
}
}

View File

@ -8,7 +8,8 @@ import {
MFA_FORM,
} from '@/app/constants';
import { mfaEventBus } from '../auth.eventBus';
import { onMounted, ref } from 'vue';
import { LAST_2FA_METHOD_KEY } from '../auth.constants';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from '@n8n/i18n';
import { toRefs } from '@vueuse/core';
import { useSettingsStore } from '@/app/stores/settings.store';
@ -27,9 +28,55 @@ import {
const props = defineProps<{
reportError: boolean;
email: string;
mfaMethod: MfaMethod;
availableMethods: MfaMethod[];
}>();
const readLastMethod = (): MfaMethod | null => {
try {
const v = localStorage.getItem(LAST_2FA_METHOD_KEY);
if (v === 'totp' || v === 'security_key' || v === 'passkey') return v;
} catch {
// localStorage unavailable
}
return null;
};
// Pick the method the user used last on this device, falling back to a
// security key when registered (passkey/security_key share the same WebAuthn
// ceremony) and finally to TOTP.
const computeInitialMethod = (): MfaMethod => {
const last = readLastMethod();
if (last && props.availableMethods.includes(last)) return last;
if (props.availableMethods.includes('security_key')) return 'security_key';
if (props.availableMethods.includes('passkey')) return 'passkey';
return 'totp';
};
const webauthnMethod = computed<'passkey' | 'security_key'>(() =>
props.availableMethods.includes('security_key') ? 'security_key' : 'passkey',
);
const currentMethod = ref<MfaMethod>(computeInitialMethod());
const showSwitcher = computed(() => {
const hasTotp = props.availableMethods.includes('totp');
const hasWebauthn =
props.availableMethods.includes('security_key') || props.availableMethods.includes('passkey');
return hasTotp && hasWebauthn;
});
const switcherTarget = computed<MfaMethod>(() =>
currentMethod.value === 'totp' ? webauthnMethod.value : 'totp',
);
const switcherLabelKey = computed(() =>
switcherTarget.value === 'totp'
? 'mfa.login.switcher.useTotp'
: switcherTarget.value === 'passkey'
? 'mfa.login.switcher.usePasskey'
: 'mfa.login.switcher.useSecurityKey',
);
const switcherIcon = computed(() => (switcherTarget.value === 'totp' ? 'shield' : 'key-round'));
const onSwitcherClick = () => {
currentMethod.value = switcherTarget.value;
};
const hasAnyChanges = ref(false);
const formBus = ref(mfaEventBus);
const formInputs = ref<null | IFormInputs>(null);
@ -194,11 +241,22 @@ const {
} = useSettingsStore();
onMounted(() => {
if (props.mfaMethod === 'totp') {
if (currentMethod.value === 'totp') {
formInputs.value = [mfaCodeFieldWithDefaults()];
focusMfaCodeAfterPasswordManager();
}
});
// When the user toggles between methods via the switcher, swap form inputs so
// the new method's primary input is auto-focused.
watch(currentMethod, (next) => {
if (next === 'totp') {
formInputs.value = [mfaCodeFieldWithDefaults()];
focusMfaCodeAfterPasswordManager();
} else {
formInputs.value = null;
}
});
</script>
<template>
@ -207,7 +265,7 @@ onMounted(() => {
<N8nCard>
<div :class="$style.headerContainer">
<N8nHeading size="xlarge" color="text-dark">
<template v-if="mfaMethod === 'totp'">{{
<template v-if="currentMethod === 'totp'">{{
showRecoveryCodeForm
? i18.baseText('mfa.recovery.modal.title')
: i18.baseText('mfa.code.modal.title')
@ -217,7 +275,7 @@ onMounted(() => {
</div>
<!-- TOTP flow -->
<template v-if="mfaMethod === 'totp'">
<template v-if="currentMethod === 'totp'">
<div :class="[$style.formContainer, reportError ? $style.formError : '']">
<N8nFormInputs
v-if="formInputs"
@ -272,6 +330,16 @@ onMounted(() => {
@click="onSaveClick"
/>
</div>
<div
v-if="showSwitcher && !showRecoveryCodeForm"
:class="$style.switcher"
data-test-id="mfa-switcher"
>
<button type="button" :class="$style.switcherLink" @click="onSwitcherClick">
<N8nIcon :icon="switcherIcon" size="xsmall" />
{{ i18.baseText(switcherLabelKey as never) }}
</button>
</div>
</template>
<!-- WebAuthn flow (passkey or security key) -->
@ -280,31 +348,31 @@ onMounted(() => {
<div
:class="[
$style.bigIcon,
$style[`tone_${mfaMethod}`],
$style[`tone_${currentMethod}`],
{ [$style.pulse]: webauthnWaiting },
]"
>
<N8nIcon :icon="mfaMethod === 'passkey' ? 'fingerprint' : 'key-round'" :size="36" />
<N8nIcon :icon="currentMethod === 'passkey' ? 'fingerprint' : 'key-round'" :size="36" />
</div>
<N8nHeading tag="h3" size="medium" color="text-dark" :class="$style.promptTitle">
<template v-if="!webauthnWaiting">{{
i18.baseText(`mfa.login.${mfaMethod}.title` as never)
i18.baseText(`mfa.login.${currentMethod}.title` as never)
}}</template>
<template v-else>{{
i18.baseText(`mfa.login.${mfaMethod}.waiting.title` as never)
i18.baseText(`mfa.login.${currentMethod}.waiting.title` as never)
}}</template>
</N8nHeading>
<N8nText size="small" color="text-base" :class="$style.promptDescription">
<template v-if="!webauthnWaiting">{{
i18.baseText(`mfa.login.${mfaMethod}.description` as never)
i18.baseText(`mfa.login.${currentMethod}.description` as never)
}}</template>
<template v-else>{{
i18.baseText(`mfa.login.${mfaMethod}.waiting.description` as never)
i18.baseText(`mfa.login.${currentMethod}.waiting.description` as never)
}}</template>
</N8nText>
</div>
<N8nNotice
v-if="mfaMethod === 'security_key' && !webauthnWaiting"
v-if="currentMethod === 'security_key' && !webauthnWaiting"
theme="info"
:content="i18.baseText('mfa.login.security_key.info')"
:class="$style.infoNotice"
@ -314,7 +382,7 @@ onMounted(() => {
</N8nText>
<N8nButton
v-if="!webauthnWaiting"
:label="i18.baseText(`mfa.login.${mfaMethod}.button` as never)"
:label="i18.baseText(`mfa.login.${currentMethod}.button` as never)"
size="large"
:class="$style.fullWidthButton"
data-test-id="mfa-webauthn-button"
@ -330,6 +398,16 @@ onMounted(() => {
@click="onWebAuthnCancel"
/>
</div>
<div
v-if="showSwitcher && !webauthnWaiting"
:class="$style.switcher"
data-test-id="mfa-switcher"
>
<button type="button" :class="$style.switcherLink" @click="onSwitcherClick">
<N8nIcon :icon="switcherIcon" size="xsmall" />
{{ i18.baseText(switcherLabelKey as never) }}
</button>
</div>
</template>
</N8nCard>
</div>
@ -463,4 +541,24 @@ body {
justify-content: flex-start;
margin-top: var(--spacing--3xs);
}
.switcher {
margin-top: var(--spacing--xs);
padding-top: var(--spacing--xs);
border-top: var(--border-width) var(--border-style) var(--color--foreground);
text-align: center;
}
.switcherLink {
color: var(--color--primary);
font-size: var(--font-size--xs);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: var(--spacing--3xs);
}
.switcherLink:hover {
text-decoration: underline;
}
</style>

View File

@ -148,14 +148,41 @@ const formatSetupDate = (iso: string) => {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date);
};
const formatCredentialDetail = (c: WebAuthnCredentialResponse) => {
const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
const formatRelativeTime = (iso: string | null) => {
if (!iso) return null;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null;
const diffSec = Math.round((date.getTime() - Date.now()) / 1000);
const abs = Math.abs(diffSec);
if (abs < 60) return relativeTimeFormatter.format(Math.round(diffSec), 'second');
if (abs < 3600) return relativeTimeFormatter.format(Math.round(diffSec / 60), 'minute');
if (abs < 86400) return relativeTimeFormatter.format(Math.round(diffSec / 3600), 'hour');
if (abs < 86400 * 30) return relativeTimeFormatter.format(Math.round(diffSec / 86400), 'day');
if (abs < 86400 * 365)
return relativeTimeFormatter.format(Math.round(diffSec / (86400 * 30)), 'month');
return relativeTimeFormatter.format(Math.round(diffSec / (86400 * 365)), 'year');
};
const formatCredentialMeta = (c: WebAuthnCredentialResponse) => {
const parts: string[] = [];
const setupDate = formatSetupDate(c.createdAt);
const setupSuffix = setupDate
? i18n.baseText('settings.personal.method.detail.setUpOn', {
if (setupDate) {
parts.push(
i18n.baseText('settings.personal.method.detail.addedOn', {
interpolate: { date: setupDate },
})
: '';
return [c.label, setupSuffix].filter(Boolean).join(' · ');
}),
);
}
const lastUsed = formatRelativeTime(c.lastUsedAt);
parts.push(
lastUsed
? i18n.baseText('settings.personal.method.detail.lastUsed', {
interpolate: { when: lastUsed },
})
: i18n.baseText('settings.personal.method.detail.neverUsed'),
);
return parts.join(' · ');
};
async function refreshWebauthnCredentials() {
@ -166,9 +193,16 @@ async function refreshWebauthnCredentials() {
}
}
async function removeWebAuthnCredential(credentialId: string) {
async function removeWebAuthnCredential(credential: WebAuthnCredentialResponse) {
// Re-verify with the same kind of authenticator the user is removing:
// `reverify` filters `allowCredentials` server-side so the OS picker can
// only satisfy the ceremony with a matching credential. Falls back to
// TOTP / recovery-code when the device is unavailable.
const kind = isPlatformCredential(credential) ? 'passkey' : 'security_key';
const proof = await reverify(kind);
if (!proof) return;
try {
await usersStore.deleteWebAuthnCredential(credentialId);
await usersStore.deleteWebAuthnCredential(credential.id, proof);
showToast({
title: i18n.baseText('settings.personal.mfa.toast.disabledMfa.title'),
message: i18n.baseText('settings.personal.mfa.toast.disabledMfa.message'),
@ -488,38 +522,8 @@ onBeforeUnmount(() => {
{{ i18n.baseText('settings.personal.passkey.description') }}
</N8nText>
</div>
<div :class="$style.methodCards">
<div
v-for="cred in passkeyCredentials"
:key="cred.id"
:class="[$style.methodCard]"
:data-test-id="`passkey-card-${cred.id}`"
>
<div :class="[$style.activeMethodIcon, $style.tone_passkey]">
<N8nIcon icon="fingerprint" />
</div>
<div :class="$style.methodCardContent">
<div :class="$style.methodCardLabel">
<N8nText :bold="true">{{ cred.label }}</N8nText>
<N8nBadge theme="success">{{
i18n.baseText('settings.personal.mfa.status.enabled')
}}</N8nBadge>
</div>
<span :class="$style.methodDetail">{{ formatCredentialDetail(cred) }}</span>
</div>
<N8nButton
variant="subtle"
size="small"
:label="i18n.baseText('settings.personal.method.button.remove')"
:data-test-id="`remove-passkey-${cred.id}`"
@click="removeWebAuthnCredential(cred.id)"
/>
</div>
<div
v-if="passkeyCredentials.length === 0"
:class="[$style.methodCard]"
data-test-id="passkey-card-disabled"
>
<div :class="$style.methodCard" data-test-id="passkey-card">
<div :class="$style.methodCardHeader">
<div :class="[$style.activeMethodIcon, $style.tone_passkey]">
<N8nIcon icon="fingerprint" />
</div>
@ -528,7 +532,14 @@ onBeforeUnmount(() => {
<N8nText :bold="true">{{
i18n.baseText('settings.personal.twoFactor.method.passkey.name')
}}</N8nText>
<N8nBadge theme="default">{{
<N8nBadge v-if="passkeyCredentials.length > 0" theme="success">{{
passkeyCredentials.length === 1
? i18n.baseText('settings.personal.method.badge.registeredOne')
: i18n.baseText('settings.personal.method.badge.registeredMany', {
interpolate: { count: String(passkeyCredentials.length) },
})
}}</N8nBadge>
<N8nBadge v-else theme="default">{{
i18n.baseText('settings.personal.method.status.notSetUp')
}}</N8nBadge>
</div>
@ -537,6 +548,7 @@ onBeforeUnmount(() => {
}}</span>
</div>
<N8nButton
v-if="passkeyCredentials.length === 0"
variant="subtle"
size="small"
:label="i18n.baseText('settings.personal.method.button.setUp')"
@ -544,14 +556,40 @@ onBeforeUnmount(() => {
@click="onSetupPasskeyClick"
/>
</div>
<N8nLink
v-if="passkeyCredentials.length > 0"
size="small"
data-test-id="add-passkey-button"
@click="onSetupPasskeyClick"
>
+ {{ i18n.baseText('settings.personal.method.button.addAnother' as never) }}
</N8nLink>
<div v-if="passkeyCredentials.length > 0" :class="$style.credList">
<div
v-for="cred in passkeyCredentials"
:key="cred.id"
:class="$style.credItem"
:data-test-id="`passkey-cred-${cred.id}`"
>
<div :class="$style.credIconSm">
<N8nIcon icon="fingerprint" size="xsmall" />
</div>
<div :class="$style.credMeta">
<div :class="$style.credName">{{ cred.label }}</div>
<div :class="$style.credSub">{{ formatCredentialMeta(cred) }}</div>
</div>
<button
type="button"
:class="$style.credTrash"
:aria-label="i18n.baseText('settings.personal.method.button.remove')"
:data-test-id="`remove-passkey-${cred.id}`"
@click="removeWebAuthnCredential(cred)"
>
<N8nIcon icon="trash-2" size="small" />
</button>
</div>
<button
type="button"
:class="$style.addAnotherBtn"
data-test-id="add-passkey-button"
@click="onSetupPasskeyClick"
>
<N8nIcon icon="plus" size="xsmall" />
{{ i18n.baseText('settings.personal.method.button.addAnother.passkey') }}
</button>
</div>
</div>
</div>
@ -571,110 +609,116 @@ onBeforeUnmount(() => {
/>
<div :class="$style.methodCards">
<!-- TOTP card -->
<div :class="[$style.methodCard]" data-test-id="mfa-method-totp">
<div :class="[$style.activeMethodIcon, $style.tone_security_key]">
<N8nIcon icon="shield" />
</div>
<div :class="$style.methodCardContent">
<div :class="$style.methodCardLabel">
<N8nText :bold="true">{{
i18n.baseText('settings.personal.twoFactor.method.totp.name')
}}</N8nText>
<N8nBadge v-if="isMethodActive('totp')" theme="success">{{
i18n.baseText('settings.personal.mfa.status.enabled')
}}</N8nBadge>
<N8nBadge v-else theme="default">{{
i18n.baseText('settings.personal.method.status.notSetUp')
}}</N8nBadge>
<div :class="$style.methodCard" data-test-id="mfa-method-totp">
<div :class="$style.methodCardHeader">
<div :class="[$style.activeMethodIcon, $style.tone_security_key]">
<N8nIcon icon="shield" />
</div>
<span :class="$style.methodDetail">{{
i18n.baseText('settings.personal.twoFactor.picker.totp.description')
}}</span>
<div :class="$style.methodCardContent">
<div :class="$style.methodCardLabel">
<N8nText :bold="true">{{
i18n.baseText('settings.personal.twoFactor.method.totp.name')
}}</N8nText>
<N8nBadge v-if="isMethodActive('totp')" theme="success">{{
i18n.baseText('settings.personal.mfa.status.enabled')
}}</N8nBadge>
<N8nBadge v-else theme="default">{{
i18n.baseText('settings.personal.method.status.notSetUp')
}}</N8nBadge>
</div>
<span :class="$style.methodDetail">{{
i18n.baseText('settings.personal.twoFactor.picker.totp.description')
}}</span>
</div>
<N8nButton
v-if="isMethodActive('totp')"
variant="destructive"
size="small"
:label="i18n.baseText('settings.personal.method.button.disable')"
data-test-id="mfa-method-totp-disable"
@click="onDisable2faClick"
/>
<N8nButton
v-else
variant="subtle"
size="small"
:label="i18n.baseText('settings.personal.method.button.setUp')"
data-test-id="mfa-method-totp-setup"
@click="onTwoFactorMethodClick('totp')"
/>
</div>
<N8nButton
v-if="isMethodActive('totp')"
variant="destructive"
size="small"
:label="i18n.baseText('settings.personal.method.button.disable')"
data-test-id="mfa-method-totp-disable"
@click="onDisable2faClick"
/>
<N8nButton
v-else
variant="subtle"
size="small"
:label="i18n.baseText('settings.personal.method.button.setUp')"
data-test-id="mfa-method-totp-setup"
@click="onTwoFactorMethodClick('totp')"
/>
</div>
<!-- Security key credentials -->
<div
v-for="cred in securityKeyCredentials"
:key="cred.id"
:class="[$style.methodCard]"
:data-test-id="`security-key-card-${cred.id}`"
>
<div :class="[$style.activeMethodIcon, $style.tone_security_key]">
<N8nIcon icon="key-round" />
</div>
<div :class="$style.methodCardContent">
<div :class="$style.methodCardLabel">
<N8nText :bold="true">{{ cred.label }}</N8nText>
<N8nBadge theme="success">{{
i18n.baseText('settings.personal.mfa.status.enabled')
}}</N8nBadge>
<!-- Security key card with optional credential list -->
<div :class="$style.methodCard" data-test-id="mfa-method-security_key">
<div :class="$style.methodCardHeader">
<div :class="[$style.activeMethodIcon, $style.tone_security_key]">
<N8nIcon icon="key-round" />
</div>
<span :class="$style.methodDetail">{{ formatCredentialDetail(cred) }}</span>
</div>
<N8nButton
variant="subtle"
size="small"
:label="i18n.baseText('settings.personal.method.button.remove')"
:data-test-id="`remove-security-key-${cred.id}`"
@click="removeWebAuthnCredential(cred.id)"
/>
</div>
<!-- Security key setup card (when none registered) -->
<div
v-if="securityKeyCredentials.length === 0"
:class="[$style.methodCard]"
data-test-id="mfa-method-security_key"
>
<div :class="[$style.activeMethodIcon, $style.tone_security_key]">
<N8nIcon icon="key-round" />
</div>
<div :class="$style.methodCardContent">
<div :class="$style.methodCardLabel">
<N8nText :bold="true">{{
i18n.baseText('settings.personal.twoFactor.method.security_key.name')
}}</N8nText>
<N8nBadge theme="default">{{
i18n.baseText('settings.personal.method.status.notSetUp')
}}</N8nBadge>
<div :class="$style.methodCardContent">
<div :class="$style.methodCardLabel">
<N8nText :bold="true">{{
i18n.baseText('settings.personal.twoFactor.method.security_key.name')
}}</N8nText>
<N8nBadge v-if="securityKeyCredentials.length > 0" theme="success">{{
securityKeyCredentials.length === 1
? i18n.baseText('settings.personal.method.badge.registeredOne')
: i18n.baseText('settings.personal.method.badge.registeredMany', {
interpolate: { count: String(securityKeyCredentials.length) },
})
}}</N8nBadge>
<N8nBadge v-else theme="default">{{
i18n.baseText('settings.personal.method.status.notSetUp')
}}</N8nBadge>
</div>
<span :class="$style.methodDetail">{{
i18n.baseText('settings.personal.twoFactor.picker.security_key.description')
}}</span>
</div>
<span :class="$style.methodDetail">{{
i18n.baseText('settings.personal.twoFactor.picker.security_key.description')
}}</span>
<N8nButton
v-if="securityKeyCredentials.length === 0"
variant="subtle"
size="small"
:label="i18n.baseText('settings.personal.method.button.setUp')"
data-test-id="mfa-method-security_key-setup"
@click="onTwoFactorMethodClick('security_key')"
/>
</div>
<div v-if="securityKeyCredentials.length > 0" :class="$style.credList">
<div
v-for="cred in securityKeyCredentials"
:key="cred.id"
:class="$style.credItem"
:data-test-id="`security-key-cred-${cred.id}`"
>
<div :class="$style.credIconSm">
<N8nIcon icon="key-round" size="xsmall" />
</div>
<div :class="$style.credMeta">
<div :class="$style.credName">{{ cred.label }}</div>
<div :class="$style.credSub">{{ formatCredentialMeta(cred) }}</div>
</div>
<button
type="button"
:class="$style.credTrash"
:aria-label="i18n.baseText('settings.personal.method.button.remove')"
:data-test-id="`remove-security-key-${cred.id}`"
@click="removeWebAuthnCredential(cred)"
>
<N8nIcon icon="trash-2" size="small" />
</button>
</div>
<button
type="button"
:class="$style.addAnotherBtn"
data-test-id="add-security-key-button"
@click="onTwoFactorMethodClick('security_key')"
>
<N8nIcon icon="plus" size="xsmall" />
{{ i18n.baseText('settings.personal.method.button.addAnother.security_key') }}
</button>
</div>
<N8nButton
variant="subtle"
size="small"
:label="i18n.baseText('settings.personal.method.button.setUp')"
data-test-id="mfa-method-security_key-setup"
@click="onTwoFactorMethodClick('security_key')"
/>
</div>
<N8nLink
v-if="securityKeyCredentials.length > 0"
size="small"
data-test-id="add-security-key-button"
@click="onTwoFactorMethodClick('security_key')"
>
+ {{ i18n.baseText('settings.personal.method.button.addAnother' as never) }}
</N8nLink>
</div>
</div>
</div>
@ -808,11 +852,16 @@ onBeforeUnmount(() => {
.methodCard {
display: flex;
gap: var(--spacing--xs);
flex-direction: column;
padding: var(--spacing--xs) var(--spacing--sm);
background: var(--background--surface);
border: var(--border-width) var(--border-style) var(--color--foreground);
border-radius: var(--radius--md);
}
.methodCardHeader {
display: flex;
gap: var(--spacing--xs);
align-items: center;
}
@ -820,6 +869,91 @@ onBeforeUnmount(() => {
opacity: 0.85;
}
.credList {
margin-top: var(--spacing--2xs);
padding-top: var(--spacing--2xs);
border-top: var(--border-width) var(--border-style) var(--color--foreground);
display: flex;
flex-direction: column;
}
.credItem {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--3xs) 0;
}
.credItem + .credItem {
border-top: var(--border-width) var(--border-style) var(--color--foreground);
}
.credIconSm {
width: var(--spacing--m);
height: var(--spacing--m);
border-radius: var(--radius--xs);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-color--subtle);
background: var(--color--background--light-2);
border: var(--border-width) var(--border-style) var(--color--foreground);
}
.credMeta {
flex: 1;
min-width: 0;
}
.credName {
font-size: var(--font-size--xs);
font-weight: var(--font-weight--medium);
color: var(--text-color);
margin-bottom: var(--spacing--5xs);
}
.credSub {
font-size: var(--font-size--2xs);
color: var(--text-color--subtle);
}
.credTrash {
flex-shrink: 0;
background: transparent;
border: none;
color: var(--text-color--subtle);
cursor: pointer;
padding: var(--spacing--3xs);
border-radius: var(--radius--xs);
}
.credTrash:hover {
color: var(--color--danger);
background: var(--color--background--light-2);
}
.addAnotherBtn {
margin-top: var(--spacing--2xs);
padding: var(--spacing--2xs) var(--spacing--xs);
font-size: var(--font-size--xs);
border-radius: var(--radius--sm);
border: 1px dashed var(--color--foreground);
background: transparent;
color: var(--text-color--subtle);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing--3xs);
}
.addAnotherBtn:hover {
border-style: solid;
border-color: var(--color--primary);
color: var(--color--primary);
}
.methodCardContent {
display: flex;
flex-direction: column;

View File

@ -16,6 +16,7 @@ import { useSSOStore } from '@/features/settings/sso/sso.store';
import type { IFormBoxConfig } from '@/Interface';
import { MFA_AUTHENTICATION_REQUIRED_ERROR_CODE, VIEWS, MFA_FORM } from '@/app/constants';
import type { LoginRequestDto, MfaMethod } from '@n8n/api-types';
import { LAST_2FA_METHOD_KEY } from '../auth.constants';
export type EmailOrLdapLoginIdAndPassword = Pick<
LoginRequestDto,
@ -38,7 +39,6 @@ const telemetry = useTelemetry();
const loading = ref(false);
const showMfaView = ref(false);
const availableMfaMethods = ref<MfaMethod[]>(['totp']);
const mfaMethod = computed<MfaMethod>(() => availableMfaMethods.value[0] ?? 'totp');
const emailOrLdapLoginId = ref('');
const password = ref('');
const reportError = ref(false);
@ -134,6 +134,18 @@ const getRedirectQueryParameter = () => {
return redirect;
};
const persistLastMethodOnSuccess = (form: LoginRequestDto) => {
let method: MfaMethod | null = null;
if (form.mfaCode) method = 'totp';
else if (form.webauthnResponse) method = 'security_key';
if (!method) return;
try {
localStorage.setItem(LAST_2FA_METHOD_KEY, method);
} catch {
// localStorage unavailable
}
};
const login = async (form: LoginRequestDto) => {
try {
loading.value = true;
@ -145,6 +157,7 @@ const login = async (form: LoginRequestDto) => {
webauthnResponse: form.webauthnResponse,
});
loading.value = false;
persistLastMethodOnSuccess(form);
await settingsStore.getSettings();
toast.clearAllStickyNotifications();
@ -277,7 +290,7 @@ onMounted(async () => {
v-if="showMfaView"
:report-error="reportError"
:email="emailOrLdapLoginId"
:mfa-method="mfaMethod"
:available-methods="availableMfaMethods"
@submit="onMFASubmitted"
@webauthn-submit="onWebAuthnSubmitted"
@on-back-click="onBackClick"

View File

@ -461,17 +461,21 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
return await webauthnApi.getCredentials(rootStore.restApiContext);
};
const deleteWebAuthnCredential = async (id: string) => {
await webauthnApi.deleteCredential(rootStore.restApiContext, id);
const deleteWebAuthnCredential = async (id: string, proof: webauthnApi.DeleteCredentialProof) => {
await webauthnApi.deleteCredential(rootStore.restApiContext, id, proof);
};
const updateWebAuthnCredentialLabel = async (id: string, label: string) => {
await webauthnApi.updateCredential(rootStore.restApiContext, id, { label });
};
const verifyWebAuthnAuthentication = async (email: string) => {
const verifyWebAuthnAuthentication = async (email: string, kind?: 'passkey' | 'security_key') => {
const { startAuthentication } = await import('@simplewebauthn/browser');
const options = await webauthnApi.getAuthenticationOptions(rootStore.restApiContext, email);
const options = await webauthnApi.getAuthenticationOptions(
rootStore.restApiContext,
email,
kind,
);
const assertionResponse = await startAuthentication({
optionsJSON: options as Parameters<typeof startAuthentication>[0]['optionsJSON'],
});